diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae88c2f --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Local CLI/dev values belong in your untracked .env file. +# This repository's GitHub workflows currently expect these values as repository secrets when the matching workflow path uses them. +# - CLOUDFLARE_API_TOKEN: required for deploy/verify flows +# - CLOUDFLARE_ACCOUNT_ID: required in this repo today, even though it is not sensitive by itself +# - CLOUDFLARE_ACCESS_CLIENT_ID / CLOUDFLARE_ACCESS_CLIENT_SECRET: optional unless you need to reach a preview protected by Cloudflare Access +# Finding the account id: +# - Account ids are assigned by Cloudflare; you do not create them in Devflare. +# - Prefer `bunx --bun devflare account` to inspect the resolved account, or copy the id from the Cloudflare dashboard account overview. +# Creating the API token: +# - Prefer `bunx --bun devflare tokens --new my-project` (same flow as `bunx devflare tokens ...`) to create a Devflare-managed account-owned token. +# - Cloudflare only shows the new token secret once, so store it immediately. +# - If you do not have a bootstrap token yet, create one in Cloudflare first with API token-management permission, then use `devflare tokens` for the reusable day-to-day token. +# Creating the Access client id / secret: +# - Open Cloudflare Zero Trust -> Access -> Service Auth -> Service Tokens -> Create service token. +# - Copy the Client ID and Client Secret immediately; Cloudflare only shows the secret once. +# - Use these for authenticated /health and /status checks against Access-protected preview workers. +# Do not store the raw Neon/Postgres connection string here once the Hyperdrive config exists in Cloudflare. +# Reference the Hyperdrive by its stable name (for example `devflare-testing`) in devflare.config.ts instead. +CLOUDFLARE_API_TOKEN=replace-with-devflare-token +CLOUDFLARE_ACCOUNT_ID=replace-with-your-cloudflare-account-id +CLOUDFLARE_ACCESS_CLIENT_ID=replace-with-access-service-token-client-id +CLOUDFLARE_ACCESS_CLIENT_SECRET=replace-with-access-service-token-client-secret diff --git a/.github/actions/devflare-deploy-impact/action.yml b/.github/actions/devflare-deploy-impact/action.yml new file mode 100644 index 0000000..e8f2afa --- /dev/null +++ b/.github/actions/devflare-deploy-impact/action.yml @@ -0,0 +1,69 @@ +name: "Devflare Deploy Impact" +description: "Determine whether a deployment target is affected by workspace or global dependency changes" +inputs: + target-package: + description: "Workspace package name to evaluate" + required: true + default-branch: + description: "Repository default branch used as a merge-base fallback when the event payload does not expose a usable previous ref" + required: false + default: "main" + event-name: + description: "GitHub event name" + required: false + default: "" + event-action: + description: "GitHub event action" + required: false + default: "" + push-before: + description: "Previous push SHA or pull_request synchronize before SHA when available" + required: false + default: "" + pull-request-base-sha: + description: "Base SHA from the pull request payload" + required: false + default: "" + pull-request-head-sha: + description: "Head SHA from the pull request payload" + required: false + default: "" + extra-paths: + description: "Optional newline-separated extra file or directory paths that should invalidate the target even when they are outside the target package root" + required: false + default: "" +outputs: + should-deploy: + description: "true when the target package or one of its relevant dependencies changed" + value: ${{ steps.resolve.outputs.should-deploy }} + reason: + description: "Short explanation for the decision" + value: ${{ steps.resolve.outputs.reason }} + comparison-base: + description: "Resolved git base ref used for the comparison" + value: ${{ steps.resolve.outputs.comparison-base }} + comparison-head: + description: "Resolved git head ref used for the comparison" + value: ${{ steps.resolve.outputs.comparison-head }} + changed-workspaces: + description: "Comma-separated list of directly changed workspace package names" + value: ${{ steps.resolve.outputs.changed-workspaces }} + changed-files: + description: "Newline-separated list of changed files that were evaluated" + value: ${{ steps.resolve.outputs.changed-files }} +runs: + using: "composite" + steps: + - name: Resolve deploy impact + id: resolve + shell: bash + env: + DEVFLARE_DEPLOY_TARGET: ${{ inputs.target-package }} + DEVFLARE_DEFAULT_BRANCH: ${{ inputs.default-branch }} + DEVFLARE_EVENT_NAME: ${{ inputs.event-name }} + DEVFLARE_EVENT_ACTION: ${{ inputs.event-action }} + DEVFLARE_PUSH_BEFORE: ${{ inputs.push-before }} + DEVFLARE_PULL_REQUEST_BASE_SHA: ${{ inputs.pull-request-base-sha }} + DEVFLARE_PULL_REQUEST_HEAD_SHA: ${{ inputs.pull-request-head-sha }} + DEVFLARE_EXTRA_PATHS: ${{ inputs.extra-paths }} + run: node ./.github/scripts/resolve-deploy-impact.mjs diff --git a/.github/actions/devflare-deploy/action.yml b/.github/actions/devflare-deploy/action.yml new file mode 100644 index 0000000..340c1db --- /dev/null +++ b/.github/actions/devflare-deploy/action.yml @@ -0,0 +1,447 @@ +name: "Devflare Deploy" +description: "Install dependencies and deploy a Devflare project to Cloudflare with an explicit production or named preview-scope target" +inputs: + working-directory: + description: "Directory containing the Devflare project" + required: false + default: "." + environment: + description: "Optional Devflare environment name. When set, it must match the explicit deploy target" + required: false + default: "" + production: + description: "When true, deploy explicitly to production via --prod" + required: false + default: "false" + preview-scope: + description: "Explicit named preview scope to deploy via --preview " + required: false + default: "" + bun-version: + description: "Bun version to install" + required: false + default: "1.3.12" + install-command: + description: "Dependency installation command to run before deploy" + required: false + default: "bun install --frozen-lockfile" + install-working-directory: + description: "Directory to run the dependency installation command from, allowing monorepo subdirectory deploys to reuse a shared root install" + required: false + default: "" + skip-setup: + description: "When true, assume Bun is already installed by an earlier workflow step and skip the local setup/cache steps" + required: false + default: "false" + skip-install: + description: "When true, assume dependencies are already installed by an earlier workflow step and skip the local install step" + required: false + default: "false" + deploy-command: + description: "Optional command prefix used to invoke deploy from the target package, for example 'bun run deploy --'. When omitted, the action falls back to 'bunx devflare deploy'." + required: false + default: "" + deploy-message: + description: "Optional Wrangler --message value applied to the Worker version/deployment" + required: false + default: "" + deploy-tag: + description: "Optional Wrangler --tag value applied to the Worker version" + required: false + default: "" + verify-deployment: + description: "When true, fail if Devflare cannot verify the uploaded version or deployment in Cloudflare's control plane" + required: false + default: "true" + require-fresh-production-deployment: + description: "When true, production deploys fail if Cloudflare keeps serving the existing live deployment instead of exposing a fresh version/deployment" + required: false + default: "false" + cloudflare-api-token: + description: "Cloudflare API token passed explicitly from the caller workflow" + required: true + cloudflare-account-id: + description: "Optional Cloudflare account ID passed explicitly from the caller workflow" + required: false + default: "" +outputs: + preview-url: + description: "Preview URL or deployed workers.dev URL returned by Devflare or Wrangler when available" + value: ${{ steps.finalize.outputs.preview_url }} + version-id: + description: "Cloudflare Worker version ID returned by Devflare or Wrangler" + value: ${{ steps.finalize.outputs.version_id }} + verification-note: + description: "Additional deployment verification context, such as when Cloudflare kept the existing active production version" + value: ${{ steps.finalize.outputs.verification_note }} + status: + description: "Deployment status reported by the action: success or failure" + value: ${{ steps.finalize.outputs.status }} + failure-stage: + description: "Stage where the action failed: setup, install, or deploy" + value: ${{ steps.finalize.outputs.failure_stage }} + exit-code: + description: "Exit code returned by devflare deploy" + value: ${{ steps.finalize.outputs.exit_code }} + log-excerpt: + description: "Tail excerpt from the deploy log for PR comments or summaries" + value: ${{ steps.finalize.outputs.log_excerpt }} +runs: + using: "composite" + steps: + - name: Setup Bun + id: setup + if: ${{ inputs.skip-setup != 'true' }} + continue-on-error: true + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Restore Bun install cache + id: bun-cache + if: ${{ inputs.skip-setup != 'true' && steps.setup.outcome == 'success' }} + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ inputs.bun-version }}-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-${{ inputs.bun-version }}- + + - name: Install dependencies + id: install + if: ${{ inputs.skip-install != 'true' && (inputs.skip-setup == 'true' || steps.setup.outcome == 'success') }} + continue-on-error: true + shell: bash + working-directory: ${{ inputs.install-working-directory != '' && inputs.install-working-directory || inputs.working-directory }} + run: | + set -euo pipefail + + install_log="$(mktemp)" + + set +e + ${{ inputs.install-command }} 2>&1 | tee "$install_log" + install_exit_code="${PIPESTATUS[0]}" + set -e + + log_excerpt="$(tail -n 40 "$install_log" | sed $'s/\r$//')" + log_delimiter="DEVFLARE_LOG_$(date +%s)_$$" + + echo "exit_code=$install_exit_code" >> "$GITHUB_OUTPUT" + { + echo "log_excerpt<<$log_delimiter" + printf '%s\n' "$log_excerpt" + echo "$log_delimiter" + } >> "$GITHUB_OUTPUT" + + rm -f "$install_log" + + exit "$install_exit_code" + + - name: Deploy with Devflare + id: deploy + if: ${{ (inputs.skip-setup == 'true' || steps.setup.outcome == 'success') && (inputs.skip-install == 'true' || steps.install.outcome == 'success') }} + continue-on-error: true + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CLOUDFLARE_API_TOKEN: ${{ inputs.cloudflare-api-token }} + CLOUDFLARE_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_PRODUCTION: ${{ inputs.production }} + INPUT_PREVIEW_SCOPE: ${{ inputs.preview-scope }} + INPUT_DEPLOY_COMMAND: ${{ inputs.deploy-command }} + INPUT_DEPLOY_MESSAGE: ${{ inputs.deploy-message }} + INPUT_DEPLOY_TAG: ${{ inputs.deploy-tag }} + DEVFLARE_VERIFY_DEPLOYMENT: ${{ inputs.verify-deployment }} + DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT: ${{ inputs.require-fresh-production-deployment }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + WORKERS_CI_BRANCH: ${{ env.WORKERS_CI_BRANCH }} + run: | + set -euo pipefail + + deploy_args=() + resolved_target='' + + target_count=0 + if [ "$INPUT_PRODUCTION" = 'true' ]; then + target_count=$((target_count + 1)) + fi + if [ -n "$INPUT_PREVIEW_SCOPE" ]; then + target_count=$((target_count + 1)) + fi + + if [ "$target_count" -eq 0 ]; then + echo 'Devflare deploy action requires one explicit target input. Set exactly one of production: "true" or preview-scope: .' >&2 + exit 1 + fi + + if [ "$target_count" -gt 1 ]; then + echo 'Choose only one explicit deploy target input: production or preview-scope.' >&2 + exit 1 + fi + + if [ -n "$INPUT_ENVIRONMENT" ]; then + if [ "$INPUT_PRODUCTION" = 'true' ] && [ "$INPUT_ENVIRONMENT" != 'production' ]; then + echo 'Production deploys can only be paired with environment: production.' >&2 + exit 1 + fi + + if [ -n "$INPUT_PREVIEW_SCOPE" ] && [ "$INPUT_ENVIRONMENT" != 'preview' ]; then + echo 'Preview deploys can only be paired with environment: preview.' >&2 + exit 1 + fi + fi + + if [ "$INPUT_PRODUCTION" = 'true' ]; then + deploy_args+=(--prod) + resolved_target='production' + elif [ -n "$INPUT_PREVIEW_SCOPE" ]; then + deploy_args+=(--preview "$INPUT_PREVIEW_SCOPE") + resolved_target="preview scope ($INPUT_PREVIEW_SCOPE)" + fi + + if [ -n "$INPUT_ENVIRONMENT" ]; then + deploy_args+=(--env "$INPUT_ENVIRONMENT") + fi + + if [ -n "$INPUT_DEPLOY_MESSAGE" ]; then + deploy_args+=(--message "$INPUT_DEPLOY_MESSAGE") + fi + + if [ -n "$INPUT_DEPLOY_TAG" ]; then + deploy_args+=(--tag "$INPUT_DEPLOY_TAG") + fi + + deploy_command="$INPUT_DEPLOY_COMMAND" + if [ -z "$deploy_command" ]; then + deploy_command='bunx devflare deploy' + fi + + deploy_log="$(mktemp)" + deploy_metadata_file="$(mktemp)" + export DEVFLARE_DEPLOY_METADATA_PATH="$deploy_metadata_file" + printf 'Using deploy command: %s\n' "$deploy_command" | tee "$deploy_log" + printf 'Deploy target: %s\n' "$resolved_target" | tee -a "$deploy_log" + printf 'Bun version: %s\n' "$(bun --version)" | tee -a "$deploy_log" + printf 'Node version: %s\n' "$(node --version)" | tee -a "$deploy_log" + if [ -f node_modules/vite/package.json ]; then + printf 'Installed vite version: %s\n' "$(node -p 'require("./node_modules/vite/package.json").version')" | tee -a "$deploy_log" + fi + set +e + bash -lc "$deploy_command \"\$@\"" bash "${deploy_args[@]}" 2>&1 | tee -a "$deploy_log" + deploy_exit_code="${PIPESTATUS[0]}" + set -e + + metadata_field_reader='const fs = require("node:fs"); const [filePath, fieldName] = process.argv.slice(1); try { const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); const value = payload?.[fieldName]; if (typeof value === "string") { process.stdout.write(value); } else if (typeof value === "number") { process.stdout.write(String(value)); } } catch {}' + + version_id="$(node -e "$metadata_field_reader" "$deploy_metadata_file" versionId)" + preview_url="$(node -e "$metadata_field_reader" "$deploy_metadata_file" previewUrl)" + verification_note="$(node -e "$metadata_field_reader" "$deploy_metadata_file" verificationNote)" + workers_dev_url="$(grep -oE 'https?:\/\/[^[:space:]]+' "$deploy_log" | grep 'workers.dev' | tail -n 1 || true)" + deploy_status='success' + if [ "$deploy_exit_code" -ne 0 ]; then + deploy_status='failure' + fi + + if [ -z "$version_id" ]; then + version_id="$(sed -nE 's/^.*(Worker )?[Vv]ersion ID:?[[:space:]]+([A-Za-z0-9_-]+).*$/\2/p' "$deploy_log" | tail -n 1)" + fi + + if [ -z "$preview_url" ]; then + preview_url="$(sed -nE 's/^.*(Preview URL|Version Preview URL|URL):[[:space:]]+(https?:\/\/[^[:space:]]+).*$/\2/p' "$deploy_log" | tail -n 1)" + fi + + if [ -z "$verification_note" ]; then + verification_note="$(sed -nE 's/^.*Deployment verification note:[[:space:]]+(.*)$/\1/p' "$deploy_log" | tail -n 1)" + fi + + preferred_preview_url="$preview_url" + if [ -z "$preferred_preview_url" ]; then + preferred_preview_url="$workers_dev_url" + fi + + log_excerpt="$(tail -n 40 "$deploy_log" | sed $'s/\r$//')" + log_delimiter="DEVFLARE_LOG_$(date +%s)_$$" + note_delimiter="DEVFLARE_NOTE_$(date +%s)_$$" + + echo "version_id=$version_id" >> "$GITHUB_OUTPUT" + echo "preview_url=$preferred_preview_url" >> "$GITHUB_OUTPUT" + echo "status=$deploy_status" >> "$GITHUB_OUTPUT" + echo "exit_code=$deploy_exit_code" >> "$GITHUB_OUTPUT" + { + echo "verification_note<<$note_delimiter" + printf '%s\n' "$verification_note" + echo "$note_delimiter" + } >> "$GITHUB_OUTPUT" + { + echo "log_excerpt<<$log_delimiter" + printf '%s\n' "$log_excerpt" + echo "$log_delimiter" + } >> "$GITHUB_OUTPUT" + + rm -f "$deploy_log" + rm -f "$deploy_metadata_file" + + exit "$deploy_exit_code" + + - name: Finalize deploy result + id: finalize + if: ${{ always() }} + shell: bash + env: + SETUP_OUTCOME: ${{ inputs.skip-setup == 'true' && 'success' || steps.setup.outcome }} + INSTALL_OUTCOME: ${{ inputs.skip-install == 'true' && 'success' || steps.install.outcome }} + INSTALL_EXIT_CODE: ${{ steps.install.outputs.exit_code }} + INSTALL_LOG_EXCERPT: ${{ steps.install.outputs.log_excerpt }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + DEPLOY_STATUS: ${{ steps.deploy.outputs.status }} + DEPLOY_EXIT_CODE: ${{ steps.deploy.outputs.exit_code }} + DEPLOY_VERSION_ID: ${{ steps.deploy.outputs.version_id }} + DEPLOY_PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} + DEPLOY_VERIFICATION_NOTE: ${{ steps.deploy.outputs.verification_note }} + DEPLOY_LOG_EXCERPT: ${{ steps.deploy.outputs.log_excerpt }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_PRODUCTION: ${{ inputs.production }} + INPUT_PREVIEW_SCOPE: ${{ inputs.preview-scope }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + + deploy_target='' + failure_stage='' + deploy_status='success' + exit_code='' + version_id='' + preview_url='' + verification_note='' + log_excerpt='' + + if [ "$SETUP_OUTCOME" != 'success' ]; then + failure_stage='setup' + deploy_status='failure' + log_excerpt='Bun setup failed before dependency installation or deploy could start. Inspect the "Setup Bun" logs inside the Devflare deploy action.' + elif [ "$INSTALL_OUTCOME" != 'success' ]; then + failure_stage='install' + deploy_status='failure' + exit_code="$INSTALL_EXIT_CODE" + log_excerpt="$INSTALL_LOG_EXCERPT" + + if [ -z "$log_excerpt" ]; then + log_excerpt='Dependency installation failed before the deploy command could start. Inspect the "Install dependencies" logs inside the Devflare deploy action.' + fi + else + deploy_status="$DEPLOY_STATUS" + if [ -z "$deploy_status" ]; then + deploy_status='failure' + fi + + if [ "$deploy_status" != 'success' ]; then + failure_stage='deploy' + fi + + exit_code="$DEPLOY_EXIT_CODE" + version_id="$DEPLOY_VERSION_ID" + preview_url="$DEPLOY_PREVIEW_URL" + verification_note="$DEPLOY_VERIFICATION_NOTE" + log_excerpt="$DEPLOY_LOG_EXCERPT" + + if [ "$DEPLOY_OUTCOME" = 'failure' ] && [ -z "$log_excerpt" ]; then + log_excerpt='The deploy command failed, but no log excerpt was captured. Inspect the "Deploy with Devflare" logs inside the Devflare deploy action.' + fi + fi + + if [ "$deploy_status" = 'success' ]; then + failure_stage='' + fi + + if [ "$INPUT_PRODUCTION" = 'true' ]; then + deploy_target='production' + elif [ -n "$INPUT_PREVIEW_SCOPE" ]; then + deploy_target="preview scope ($INPUT_PREVIEW_SCOPE)" + else + deploy_target='unknown' + fi + + log_delimiter="DEVFLARE_LOG_$(date +%s)_$$" + note_delimiter="DEVFLARE_NOTE_$(date +%s)_$$" + + echo "version_id=$version_id" >> "$GITHUB_OUTPUT" + echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" + echo "status=$deploy_status" >> "$GITHUB_OUTPUT" + echo "failure_stage=$failure_stage" >> "$GITHUB_OUTPUT" + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + { + echo "verification_note<<$note_delimiter" + printf '%s\n' "$verification_note" + echo "$note_delimiter" + } >> "$GITHUB_OUTPUT" + { + echo "log_excerpt<<$log_delimiter" + printf '%s\n' "$log_excerpt" + echo "$log_delimiter" + } >> "$GITHUB_OUTPUT" + + { + if [ "$deploy_status" = 'success' ]; then + echo '### โœ… Devflare deployment succeeded' + else + echo '### โŒ Devflare deployment failed' + fi + echo '' + echo "- Working directory: \`$INPUT_WORKING_DIRECTORY\`" + echo "- Deploy target: \`$deploy_target\`" + echo "- Status: \`$deploy_status\`" + if [ -n "$failure_stage" ]; then + echo "- Failure stage: \`$failure_stage\`" + fi + if [ -n "$exit_code" ]; then + echo "- Exit code: \`$exit_code\`" + fi + if [ -n "$INPUT_ENVIRONMENT" ]; then + echo "- Environment: \`$INPUT_ENVIRONMENT\`" + fi + if [ -n "$version_id" ]; then + echo "- Version ID: \`$version_id\`" + fi + if [ -n "$verification_note" ]; then + echo "- Verification note: $verification_note" + fi + if [ -n "$preview_url" ]; then + echo "- Preview URL: $preview_url" + fi + if [ -n "$log_excerpt" ] && [ "$deploy_status" != 'success' ]; then + echo '' + echo '#### Log excerpt' + echo '' + echo '```text' + printf '%s\n' "$log_excerpt" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Fail action when deploy did not succeed + if: ${{ steps.finalize.outputs.status != 'success' }} + shell: bash + env: + DEVFLARE_FAILURE_STAGE: ${{ steps.finalize.outputs.failure_stage }} + DEVFLARE_EXIT_CODE: ${{ steps.finalize.outputs.exit_code }} + DEVFLARE_LOG_EXCERPT: ${{ steps.finalize.outputs.log_excerpt }} + run: | + echo 'Devflare deploy action failed.' >&2 + + if [ -n "$DEVFLARE_FAILURE_STAGE" ]; then + echo "Failure stage: $DEVFLARE_FAILURE_STAGE" >&2 + fi + + if [ -n "$DEVFLARE_EXIT_CODE" ]; then + echo "Exit code: $DEVFLARE_EXIT_CODE" >&2 + fi + + if [ -n "$DEVFLARE_LOG_EXCERPT" ]; then + echo '' >&2 + echo 'Log excerpt:' >&2 + printf '%s\n' "$DEVFLARE_LOG_EXCERPT" >&2 + fi + + exit 1 diff --git a/.github/actions/devflare-github-feedback/action.yml b/.github/actions/devflare-github-feedback/action.yml new file mode 100644 index 0000000..848ec9d --- /dev/null +++ b/.github/actions/devflare-github-feedback/action.yml @@ -0,0 +1,114 @@ +name: "Devflare GitHub Feedback" +description: "Publish Devflare deployment feedback as a PR comment, a GitHub deployment, or both" +inputs: + github-token: + description: "GitHub token used to create PR comments and deployment statuses" + required: true + mode: + description: "Feedback surface to update: comment, deployment, or both" + required: false + default: "comment" + operation: + description: "Whether to publish a new report or clean up existing feedback" + required: false + default: "report" + status: + description: "Deployment status to publish: success, failure, in_progress, inactive, or skipped" + required: true + title: + description: "Human-readable title shown in the PR comment or deployment description" + required: true + comment-key: + description: "Stable marker key used to find and update an existing PR comment" + required: false + default: "" + comment-section-key: + description: "Optional section key used to merge this feedback into a shared PR comment instead of replacing the entire comment body" + required: false + default: "" + comment-group-title: + description: "Optional shared heading rendered above grouped PR comment sections" + required: false + default: "" + comment-group-summary: + description: "Optional shared summary paragraph rendered above grouped PR comment sections" + required: false + default: "" + pr-number: + description: "Explicit pull request number to update when comment mode is enabled" + required: false + default: "" + resolve-pr-from-ref: + description: "When true, look up an open PR for ref-name and update it when one exists" + required: false + default: "false" + deployment-kind: + description: "Logical deployment kind: preview or production" + required: false + default: "preview" + ref-name: + description: "Branch or ref name associated with the feedback" + required: false + default: "" + sha: + description: "Commit SHA to attach to a GitHub deployment" + required: false + default: "" + environment: + description: "GitHub deployment environment name" + required: false + default: "" + environment-url: + description: "Primary environment URL for deployment statuses" + required: false + default: "" + preview-url: + description: "Preview URL to include in PR comments" + required: false + default: "" + production-url: + description: "Production URL to include in PR comments" + required: false + default: "" + version-id: + description: "Cloudflare Worker version ID to include in feedback" + required: false + default: "" + log-url: + description: "Workflow run or log URL to include in feedback" + required: false + default: "" + log-excerpt: + description: "Optional log excerpt rendered in a collapsible PR comment section" + required: false + default: "" + summary: + description: "Optional summary paragraph shown below the heading" + required: false + default: "" + details-markdown: + description: "Optional extra markdown appended inside the collapsible details section" + required: false + default: "" + ignore-comment-permission-errors: + description: "When true, skip PR comment updates that are forbidden for the current GitHub token instead of failing the action" + required: false + default: "true" + transient-environment: + description: "Override the transient_environment flag for GitHub deployments" + required: false + default: "" + production-environment: + description: "Override the production_environment flag for GitHub deployments" + required: false + default: "" +outputs: + comment-id: + description: "Updated PR comment id when comment mode runs successfully" + deployment-id: + description: "Created or updated deployment id when deployment mode runs successfully" + pr-number: + description: "Resolved pull request number used for comment mode" +runs: + using: "node24" + main: "index.js" diff --git a/.github/actions/devflare-github-feedback/index.js b/.github/actions/devflare-github-feedback/index.js new file mode 100644 index 0000000..6e0ad6b --- /dev/null +++ b/.github/actions/devflare-github-feedback/index.js @@ -0,0 +1,980 @@ +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const githubApiBaseUrl = process.env.GITHUB_API_URL?.trim() || + "https://api.github.com"; + +class GitHubRequestError extends Error { + constructor(method, path, status, responseText) { + super( + `GitHub API ${method} ${path} failed (${status}): ${responseText}`, + ); + this.name = "GitHubRequestError"; + this.method = method; + this.path = path; + this.status = status; + this.responseText = responseText; + } +} + +function getInputEnvironmentKeys(name) { + const normalizedName = name.replace(/ /g, "_").toUpperCase(); + return [ + ...new Set([ + `INPUT_${normalizedName}`, + `INPUT_${normalizedName.replace(/-/g, "_")}`, + ]), + ]; +} + +export function getInput(name) { + for (const envKey of getInputEnvironmentKeys(name)) { + const value = process.env[envKey]; + if (typeof value !== "undefined") { + return value; + } + } + + return ""; +} + +function getOptionalInput(name) { + const value = getInput(name).trim(); + return value ? value : undefined; +} + +function getBooleanInput(name, fallback = false) { + const value = getInput(name).trim().toLowerCase(); + if (!value) { + return fallback; + } + + return value === "true" || value === "1" || value === "yes" || + value === "on"; +} + +function slugify(value) { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || "devflare-feedback"; +} + +function truncate(value, maxLength) { + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 1) { + return "โ€ฆ"; + } + + return `${value.slice(0, maxLength - 1)}โ€ฆ`; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function shortSha(value) { + return value.length <= 12 ? value : value.slice(0, 12); +} + +function toLink(url, label) { + return `[${label}](${url})`; +} + +function sanitizeCodeFenceContent(value) { + return value.replaceAll("```", "``\u200b`"); +} + +function setOutput(name, value) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + return; + } + + appendFileSync(outputPath, `${name}=${value ?? ""}\n`); +} + +function log(message) { + console.log(`[devflare-github-feedback] ${message}`); +} + +function warn(message) { + console.warn(`[devflare-github-feedback] ${message}`); +} + +function parseNumber(value) { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseRepository() { + const repository = process.env.GITHUB_REPOSITORY?.trim(); + if (!repository || !repository.includes("/")) { + throw new Error("GITHUB_REPOSITORY is not available"); + } + + const [owner, repo] = repository.split("/", 2); + return { + owner, + repo, + }; +} + +function getDefaultRunUrl() { + const serverUrl = process.env.GITHUB_SERVER_URL?.trim(); + const repository = process.env.GITHUB_REPOSITORY?.trim(); + const runId = process.env.GITHUB_RUN_ID?.trim(); + if (!serverUrl || !repository || !runId) { + return undefined; + } + + return `${serverUrl}/${repository}/actions/runs/${runId}`; +} + +function parsePayload(payload) { + if (!payload) { + return {}; + } + + if (typeof payload === "string") { + try { + return JSON.parse(payload); + } catch { + return {}; + } + } + + if (typeof payload === "object") { + return payload; + } + + return {}; +} + +function getStatusPresentation(config) { + if (config.operation === "cleanup" || config.status === "inactive") { + return { + emoji: "๐Ÿงน", + suffix: "retired", + }; + } + + switch (config.status) { + case "success": { + return { + emoji: "โœ…", + suffix: "deployed successfully", + }; + } + + case "skipped": { + return { + emoji: "โญ๏ธ", + suffix: "was unchanged", + }; + } + + case "failure": { + return { + emoji: "โŒ", + suffix: "failed", + }; + } + + case "in_progress": { + return { + emoji: "โณ", + suffix: "is running", + }; + } + + default: { + throw new Error(`Unsupported feedback status: ${config.status}`); + } + } +} + +function buildSummary(config) { + if (config.summary) { + return config.summary; + } + + if (config.operation === "cleanup" || config.status === "inactive") { + return config.deploymentKind === "production" + ? "This deployment feedback was retired after the related lifecycle completed." + : "This preview was retired after the related pull request or branch lifecycle completed."; + } + + if (config.status === "failure") { + return config.deploymentKind === "production" + ? "The production deployment failed before Devflare could confirm a healthy result." + : "The preview deployment failed before Devflare could confirm a healthy result."; + } + + if (config.status === "skipped") { + return config.deploymentKind === "production" + ? "No new production deployment was needed for this run, so the latest verified production state remains in place." + : "No new preview deployment was needed for this run, so the existing stable preview remains in place."; + } + + if (config.status === "in_progress") { + return "GitHub has accepted the deployment request and the latest run is still in progress."; + } + + return config.deploymentKind === "production" + ? "Devflare verified the latest production deployment through Cloudflare control-plane checks." + : "Devflare verified the latest preview deployment through Cloudflare control-plane checks."; +} + +function buildCommentHeading(config, headingLevel = 2) { + const presentation = getStatusPresentation(config); + return `${ + "#".repeat(headingLevel) + } ${presentation.emoji} ${config.title} ${presentation.suffix}`; +} + +export function buildCommentBody( + config, + { includeMarker = true, headingLevel = 2 } = {}, +) { + const lines = [ + ...(includeMarker ? [config.commentMarker] : []), + buildCommentHeading(config, headingLevel), + "", + buildSummary(config), + "", + ]; + + const previewUrl = config.previewUrl ?? config.environmentUrl; + const productionUrl = config.deploymentKind === "production" + ? config.productionUrl + : undefined; + + if (previewUrl) { + lines.push( + `- ${ + config.operation === "cleanup" || config.status === "inactive" + ? "Last preview URL" + : "Preview URL" + }: ${toLink(previewUrl, previewUrl)}`, + ); + } + + if (productionUrl) { + lines.push(`- Production URL: ${toLink(productionUrl, productionUrl)}`); + } + + if (config.versionId) { + lines.push(`- Version ID: \`${config.versionId}\``); + } + + if (config.environment) { + lines.push(`- GitHub environment: \`${config.environment}\``); + } + + if (config.refName) { + lines.push(`- Ref: \`${config.refName}\``); + } + + if (config.sha) { + lines.push(`- Commit: \`${shortSha(config.sha)}\``); + } + + if (config.logUrl) { + lines.push(`- Workflow run: ${toLink(config.logUrl, "View run")}`); + } + + const detailLines = []; + if (config.logUrl) { + detailLines.push( + `- Full workflow logs: ${toLink(config.logUrl, config.logUrl)}`, + ); + } + + if (config.logExcerpt) { + detailLines.push( + "", + "```text", + sanitizeCodeFenceContent(config.logExcerpt), + "```", + ); + } + + if (config.detailsMarkdown) { + detailLines.push("", config.detailsMarkdown); + } + + if (detailLines.length > 0) { + lines.push( + "", + "
", + "Logs and details", + "", + ...detailLines, + "", + "
", + ); + } + + return `${lines.join("\n").trim()}\n`; +} + +function usesGroupedComment(config) { + return Boolean(config.commentSectionKey); +} + +function getCommentGroupTitle(config) { + return config.commentGroupTitle ?? "Deployment status"; +} + +function getCommentGroupSummary(config) { + return config.commentGroupSummary ?? + "This single comment tracks the latest deployment feedback for this pull request and is updated in place by the related Devflare workflows."; +} + +function getCommentSectionStartMarker(commentKey, sectionKey) { + return ``; +} + +function getCommentSectionEndMarker(commentKey, sectionKey) { + return ``; +} + +export function parseGroupedCommentSections(commentKey, body) { + const sections = new Map(); + if (!body) { + return sections; + } + + const escapedCommentKey = escapeRegExp(commentKey); + const pattern = new RegExp( + `\\s*([\\s\\S]*?)\\s*`, + "g", + ); + + for (const match of body.matchAll(pattern)) { + const sectionKey = match[1]; + const sectionBody = match[2]?.trim(); + if (sectionKey && sectionBody) { + sections.set(sectionKey, sectionBody); + } + } + + return sections; +} + +export function buildGroupedCommentBody(config, sections) { + const orderedSections = [...sections.entries()].sort(( + [leftKey], + [rightKey], + ) => leftKey.localeCompare(rightKey)); + const lines = [ + config.commentMarker, + `## ${getCommentGroupTitle(config)}`, + "", + getCommentGroupSummary(config), + ]; + + for (const [sectionKey, sectionBody] of orderedSections) { + lines.push( + "", + getCommentSectionStartMarker(config.commentKey, sectionKey), + sectionBody.trim(), + getCommentSectionEndMarker(config.commentKey, sectionKey), + ); + } + + return `${lines.join("\n").trim()}\n`; +} + +function sortCommentsById(comments) { + return [...comments].sort((left, right) => { + const leftId = Number(left?.id ?? 0); + const rightId = Number(right?.id ?? 0); + return leftId - rightId; + }); +} + +function buildGroupedCommentSectionBody(config) { + return buildCommentBody(config, { + includeMarker: false, + headingLevel: 3, + }).trim(); +} + +function mergeGroupedCommentSections(config, comments) { + const sections = new Map(); + for (const comment of sortCommentsById(comments)) { + for ( + const [sectionKey, sectionBody] of parseGroupedCommentSections( + config.commentKey, + comment.body, + ) + ) { + sections.set(sectionKey, sectionBody); + } + } + + sections.set( + config.commentSectionKey, + buildGroupedCommentSectionBody(config), + ); + return sections; +} + +function buildDeploymentDescription(config) { + if (config.operation === "cleanup" || config.status === "inactive") { + return truncate(`${config.title} retired`, 140); + } + + switch (config.status) { + case "success": { + return truncate(`${config.title} deployed successfully`, 140); + } + + case "skipped": { + return truncate(`${config.title} was unchanged`, 140); + } + + case "failure": { + return truncate(`${config.title} failed`, 140); + } + + case "in_progress": { + return truncate(`${config.title} is running`, 140); + } + + default: { + return truncate(`${config.title} updated`, 140); + } + } +} + +function mapDeploymentStatus(status) { + switch (status) { + case "success": + case "failure": + case "in_progress": + case "inactive": { + return status; + } + + default: { + throw new Error(`Unsupported deployment status: ${status}`); + } + } +} + +function parseGitHubErrorMessage(responseText) { + if (!responseText) { + return undefined; + } + + try { + const parsed = JSON.parse(responseText); + return typeof parsed?.message === "string" ? parsed.message : undefined; + } catch { + return undefined; + } +} + +function isCommentPermissionError(error) { + if (!(error instanceof GitHubRequestError)) { + return false; + } + + if (error.status !== 403) { + return false; + } + + if (!error.path.includes("/issues/") || !error.path.includes("/comments")) { + return false; + } + + const message = parseGitHubErrorMessage(error.responseText); + return message === "Resource not accessible by integration"; +} + +function toErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function githubRequest(token, method, path, body) { + const response = await fetch(`${githubApiBaseUrl}${path}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "devflare-github-feedback", + "x-github-api-version": "2022-11-28", + ...(body ? { "content-type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (response.status === 204) { + return null; + } + + const text = await response.text(); + if (!response.ok) { + throw new GitHubRequestError(method, path, response.status, text); + } + + return text ? JSON.parse(text) : null; +} + +async function resolvePrNumber(config) { + if (config.prNumber) { + return config.prNumber; + } + + if (!config.resolvePrFromRef || !config.refName) { + return undefined; + } + + const query = new URLSearchParams({ + state: "open", + head: `${config.owner}:${config.refName}`, + per_page: "1", + }); + const pulls = await githubRequest( + config.githubToken, + "GET", + `/repos/${config.owner}/${config.repo}/pulls?${query.toString()}`, + ); + + if (!Array.isArray(pulls) || pulls.length === 0) { + log(`No open pull request found for ${config.refName}, skipping PR comment update`); + return undefined; + } + + const number = pulls[0]?.number; + return typeof number === "number" && number > 0 ? number : undefined; +} + +async function listMatchingPrComments(config, prNumber) { + const comments = await githubRequest( + config.githubToken, + "GET", + `/repos/${config.owner}/${config.repo}/issues/${prNumber}/comments?per_page=100`, + ); + + return Array.isArray(comments) + ? sortCommentsById( + comments.filter( + (comment) => + typeof comment.body === "string" && + comment.body.includes(config.commentMarker), + ), + ) + : []; +} + +async function updatePrComment(config, commentId, body, prNumber) { + const updated = await githubRequest( + config.githubToken, + "PATCH", + `/repos/${config.owner}/${config.repo}/issues/comments/${commentId}`, + { body }, + ); + log(`Updated PR comment #${commentId} on pull request #${prNumber}`); + return updated?.id ?? commentId; +} + +async function createPrComment(config, prNumber, body) { + const created = await githubRequest( + config.githubToken, + "POST", + `/repos/${config.owner}/${config.repo}/issues/${prNumber}/comments`, + { body }, + ); + log(`Created PR comment on pull request #${prNumber}`); + return created?.id; +} + +async function deletePrComment(config, commentId) { + await githubRequest( + config.githubToken, + "DELETE", + `/repos/${config.owner}/${config.repo}/issues/comments/${commentId}`, + ); + log(`Deleted duplicate PR comment #${commentId}`); +} + +async function dedupePrComments(config, prNumber, body) { + const matchingComments = await listMatchingPrComments(config, prNumber); + if (matchingComments.length === 0) { + return undefined; + } + + const [canonicalComment, ...duplicateComments] = matchingComments; + let commentId = canonicalComment.id; + if (canonicalComment.body !== body) { + commentId = await updatePrComment( + config, + canonicalComment.id, + body, + prNumber, + ); + } + + for (const duplicateComment of duplicateComments) { + if (duplicateComment.id === canonicalComment.id) { + continue; + } + + await deletePrComment(config, duplicateComment.id); + } + + return commentId; +} + +async function upsertPrComment(config, prNumber) { + const matchingComments = await listMatchingPrComments(config, prNumber); + + if (usesGroupedComment(config)) { + const body = buildGroupedCommentBody( + config, + mergeGroupedCommentSections(config, matchingComments), + ); + + if (matchingComments.length > 0) { + const [canonicalComment] = matchingComments; + await updatePrComment(config, canonicalComment.id, body, prNumber); + return await dedupePrComments(config, prNumber, body); + } + + await createPrComment(config, prNumber, body); + const mergedBody = buildGroupedCommentBody( + config, + mergeGroupedCommentSections( + config, + await listMatchingPrComments(config, prNumber), + ), + ); + return await dedupePrComments(config, prNumber, mergedBody); + } + + const body = buildCommentBody(config); + if (matchingComments.length > 0) { + const [canonicalComment] = matchingComments; + await updatePrComment(config, canonicalComment.id, body, prNumber); + return await dedupePrComments(config, prNumber, body); + } + + await createPrComment(config, prNumber, body); + return await dedupePrComments(config, prNumber, body); +} + +async function createDeployment(config) { + if (!config.refName && !config.sha) { + throw new Error("Deployment feedback requires ref-name or sha"); + } + + const deployment = await githubRequest( + config.githubToken, + "POST", + `/repos/${config.owner}/${config.repo}/deployments`, + { + ref: config.sha ?? config.refName, + task: config.deploymentKind === "production" + ? "deploy" + : "deploy:preview", + auto_merge: false, + required_contexts: [], + environment: config.environment, + description: buildDeploymentDescription(config), + transient_environment: config.transientEnvironment, + production_environment: config.productionEnvironment, + payload: { + commentKey: config.commentKey, + deploymentKind: config.deploymentKind, + refName: config.refName, + title: config.title, + }, + }, + ); + + await githubRequest( + config.githubToken, + "POST", + `/repos/${config.owner}/${config.repo}/deployments/${deployment.id}/statuses`, + { + state: mapDeploymentStatus(config.status), + environment: config.environment, + environment_url: config.environmentUrl, + log_url: config.logUrl, + description: buildDeploymentDescription(config), + auto_inactive: config.status === "success", + }, + ); + + log(`Created deployment ${deployment.id} for ${config.environment}`); + return deployment.id; +} + +function matchesCleanupTarget(config, deployment) { + if (config.environment && deployment.environment !== config.environment) { + return false; + } + + const payload = parsePayload(deployment.payload); + if ( + config.refName && payload.refName && payload.refName !== config.refName + ) { + return false; + } + + if ( + config.commentKey && payload.commentKey && + payload.commentKey !== config.commentKey + ) { + return false; + } + + if (config.refName && payload.refName === config.refName) { + return true; + } + + if (config.commentKey && payload.commentKey === config.commentKey) { + return true; + } + + return Boolean(config.environment); +} + +async function deactivateDeployments(config) { + if (!config.environment && !config.refName && !config.commentKey) { + throw new Error( + "Deployment cleanup requires environment, ref-name, or comment-key to find existing deployments", + ); + } + + const query = new URLSearchParams({ + per_page: "100", + ...(config.environment ? { environment: config.environment } : {}), + }); + const deployments = await githubRequest( + config.githubToken, + "GET", + `/repos/${config.owner}/${config.repo}/deployments?${query.toString()}`, + ); + const matchingDeployments = Array.isArray(deployments) + ? deployments.filter((deployment) => + matchesCleanupTarget(config, deployment) + ) + : []; + + if (matchingDeployments.length === 0) { + log("No matching deployments found to retire"); + return undefined; + } + + let lastDeploymentId; + for (const deployment of matchingDeployments) { + await githubRequest( + config.githubToken, + "POST", + `/repos/${config.owner}/${config.repo}/deployments/${deployment.id}/statuses`, + { + state: "inactive", + environment: config.environment, + environment_url: config.environmentUrl, + log_url: config.logUrl, + description: buildDeploymentDescription(config), + }, + ); + lastDeploymentId = deployment.id; + } + + log(`Marked ${matchingDeployments.length} deployment(s) inactive`); + return lastDeploymentId; +} + +export function buildConfig() { + const { owner, repo } = parseRepository(); + const githubToken = getOptionalInput("github-token"); + if (!githubToken) { + throw new Error("github-token is required"); + } + + const title = getOptionalInput("title"); + if (!title) { + throw new Error("title is required"); + } + + const deploymentKind = getOptionalInput("deployment-kind") ?? "preview"; + const commentKey = getOptionalInput("comment-key") ?? slugify(title); + const commentSectionKeyInput = getOptionalInput("comment-section-key"); + const previewUrl = getOptionalInput("preview-url"); + const productionUrl = deploymentKind === "production" + ? getOptionalInput("production-url") + : undefined; + const environmentUrl = getOptionalInput("environment-url") ?? + (deploymentKind === "production" ? productionUrl : previewUrl); + const environment = getOptionalInput("environment") ?? title; + const refName = getOptionalInput("ref-name"); + const sha = getOptionalInput("sha"); + const mode = getOptionalInput("mode") ?? "comment"; + const operation = getOptionalInput("operation") ?? "report"; + const status = getOptionalInput("status"); + if (!status) { + throw new Error("status is required"); + } + + return { + owner, + repo, + githubToken, + title, + commentKey, + commentMarker: ``, + commentSectionKey: commentSectionKeyInput + ? slugify(commentSectionKeyInput) + : undefined, + commentGroupTitle: getOptionalInput("comment-group-title"), + commentGroupSummary: getOptionalInput("comment-group-summary"), + mode, + operation, + status, + deploymentKind, + prNumber: parseNumber(getOptionalInput("pr-number")), + resolvePrFromRef: getBooleanInput("resolve-pr-from-ref"), + refName, + sha, + environment, + environmentUrl, + previewUrl, + productionUrl, + versionId: getOptionalInput("version-id"), + logUrl: getOptionalInput("log-url") ?? getDefaultRunUrl(), + logExcerpt: getOptionalInput("log-excerpt"), + summary: getOptionalInput("summary"), + detailsMarkdown: getOptionalInput("details-markdown"), + transientEnvironment: getBooleanInput( + "transient-environment", + deploymentKind !== "production", + ), + productionEnvironment: getBooleanInput( + "production-environment", + deploymentKind === "production", + ), + ignoreCommentPermissionErrors: getBooleanInput( + "ignore-comment-permission-errors", + true, + ), + }; +} + +function wantsComment(config) { + return config.mode === "comment" || config.mode === "both"; +} + +function wantsDeployment(config) { + return config.mode === "deployment" || config.mode === "both"; +} + +async function runCommentFeedback(config) { + const resolvedPrNumber = await resolvePrNumber(config); + if (resolvedPrNumber) { + return { + resolvedPrNumber, + commentId: await upsertPrComment(config, resolvedPrNumber), + }; + } + + if (config.resolvePrFromRef && config.refName) { + log("Skipping PR comment because no matching open pull request was found"); + return { + resolvedPrNumber, + commentId: undefined, + }; + } + + throw new Error( + "Comment feedback requires pr-number or resolve-pr-from-ref with ref-name", + ); +} + +function handleCommentFeedbackFailure(config, error, failures) { + if ( + config.ignoreCommentPermissionErrors && isCommentPermissionError(error) + ) { + warn( + "Skipping PR comment update because the current GitHub token cannot write issue comments for this run (403 Resource not accessible by integration)", + ); + return; + } + + failures.push(toErrorMessage(error)); +} + +async function runDeploymentFeedback(config) { + if (config.status === "skipped") { + throw new Error( + 'Deployment feedback does not support status "skipped". Use comment mode when no deployment update is needed.', + ); + } + + return config.operation === "cleanup" || config.status === "inactive" + ? await deactivateDeployments(config) + : await createDeployment(config); +} + +function writeActionOutputs({ commentId, deploymentId, resolvedPrNumber }) { + setOutput("comment-id", commentId ?? ""); + setOutput("deployment-id", deploymentId ?? ""); + setOutput("pr-number", resolvedPrNumber ?? ""); +} + +export async function main() { + const config = buildConfig(); + const failures = []; + let resolvedPrNumber = config.prNumber; + let commentId; + let deploymentId; + + if (wantsComment(config)) { + try { + const commentFeedback = await runCommentFeedback(config); + resolvedPrNumber = commentFeedback.resolvedPrNumber; + commentId = commentFeedback.commentId; + } catch (error) { + handleCommentFeedbackFailure(config, error, failures); + } + } + + if (wantsDeployment(config)) { + try { + deploymentId = await runDeploymentFeedback(config); + } catch (error) { + failures.push(toErrorMessage(error)); + } + } + + writeActionOutputs({ + commentId, + deploymentId, + resolvedPrNumber, + }); + + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } +} + +if ( + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +) { + await main(); +} diff --git a/.github/actions/devflare-github-feedback/index.test.js b/.github/actions/devflare-github-feedback/index.test.js new file mode 100644 index 0000000..fa0ee78 --- /dev/null +++ b/.github/actions/devflare-github-feedback/index.test.js @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + buildCommentBody, + buildConfig, + buildGroupedCommentBody, + getInput, + parseGroupedCommentSections, +} from "./index.js"; + +const trackedEnvKeys = [ + "GITHUB_REPOSITORY", + "INPUT_GITHUB-TOKEN", + "INPUT_GITHUB_TOKEN", + "INPUT_TITLE", + "INPUT_STATUS", + "INPUT_COMMENT-SECTION-KEY", + "INPUT_COMMENT_SECTION_KEY", + "INPUT_COMMENT-GROUP-TITLE", + "INPUT_COMMENT_GROUP_TITLE", + "INPUT_COMMENT-GROUP-SUMMARY", + "INPUT_COMMENT_GROUP_SUMMARY", +]; + +const originalEnv = new Map( + trackedEnvKeys.map((envKey) => [envKey, process.env[envKey]]), +); + +function resetTrackedEnv() { + for (const envKey of trackedEnvKeys) { + const originalValue = originalEnv.get(envKey); + if (typeof originalValue === "undefined") { + delete process.env[envKey]; + continue; + } + + process.env[envKey] = originalValue; + } +} + +afterEach(() => { + resetTrackedEnv(); +}); + +describe("devflare-github-feedback inputs", () => { + test("reads hyphenated GitHub Actions input env keys", () => { + process.env["INPUT_GITHUB-TOKEN"] = "github-token-from-runner"; + + expect(getInput("github-token")).toBe("github-token-from-runner"); + }); + + test("also accepts underscore input env keys as a compatibility fallback", () => { + process.env.INPUT_GITHUB_TOKEN = "github-token-from-fallback"; + + expect(getInput("github-token")).toBe("github-token-from-fallback"); + }); + + test("buildConfig succeeds when required inputs come from hyphenated env keys", () => { + process.env.GITHUB_REPOSITORY = "Refzlund/devflare"; + process.env["INPUT_GITHUB-TOKEN"] = "github-token-from-runner"; + process.env.INPUT_TITLE = "Documentation production"; + process.env.INPUT_STATUS = "failure"; + + const config = buildConfig(); + + expect(config.githubToken).toBe("github-token-from-runner"); + expect(config.title).toBe("Documentation production"); + expect(config.status).toBe("failure"); + }); + + test("buildConfig reads grouped comment inputs", () => { + process.env.GITHUB_REPOSITORY = "Refzlund/devflare"; + process.env["INPUT_GITHUB-TOKEN"] = "github-token-from-runner"; + process.env.INPUT_TITLE = "Testing PR preview"; + process.env.INPUT_STATUS = "success"; + process.env["INPUT_COMMENT-SECTION-KEY"] = "testing-preview"; + process.env["INPUT_COMMENT-GROUP-TITLE"] = + "Pull request deployment status"; + process.env["INPUT_COMMENT-GROUP-SUMMARY"] = + "Shared preview status comment"; + + const config = buildConfig(); + + expect(config.commentSectionKey).toBe("testing-preview"); + expect(config.commentGroupTitle).toBe("Pull request deployment status"); + expect(config.commentGroupSummary).toBe( + "Shared preview status comment", + ); + }); +}); + +describe("grouped comment rendering", () => { + function createConfig(overrides = {}) { + return { + commentMarker: "", + commentKey: "pr-deployment-status", + title: "Documentation PR preview", + status: "success", + operation: "report", + deploymentKind: "preview", + previewUrl: "https://docs-preview.example.workers.dev", + environmentUrl: "https://docs-preview.example.workers.dev", + refName: "next", + sha: "c027eca929d53edb9fa30653f185ba2da0b5c9f0", + logUrl: "https://github.com/Refzlund/devflare/actions/runs/1", + commentSectionKey: "documentation-preview", + commentGroupTitle: "Pull request deployment status", + commentGroupSummary: + "This single comment tracks the latest documentation and testing preview results for the pull request.", + ...overrides, + }; + } + + test("renders grouped comments with multiple workflow sections", () => { + const documentationConfig = createConfig(); + const testingConfig = createConfig({ + title: "Testing PR preview", + previewUrl: "https://testing-preview.example.workers.dev", + environmentUrl: "https://testing-preview.example.workers.dev", + commentSectionKey: "testing-preview", + }); + + const body = buildGroupedCommentBody( + documentationConfig, + new Map([ + [ + documentationConfig.commentSectionKey, + buildCommentBody(documentationConfig, { + includeMarker: false, + headingLevel: 3, + }).trim(), + ], + [ + testingConfig.commentSectionKey, + buildCommentBody(testingConfig, { + includeMarker: false, + headingLevel: 3, + }).trim(), + ], + ]), + ); + + expect(body).toContain("## Pull request deployment status"); + expect(body).toContain( + "### โœ… Documentation PR preview deployed successfully", + ); + expect(body).toContain( + "### โœ… Testing PR preview deployed successfully", + ); + expect(body.indexOf("Documentation PR preview")).toBeLessThan( + body.indexOf("Testing PR preview"), + ); + }); + + test("parses grouped comment sections from an existing shared comment body", () => { + const body = buildGroupedCommentBody( + createConfig(), + new Map([ + [ + "documentation-preview", + "### โœ… Documentation PR preview deployed successfully\n\nDocumentation section", + ], + [ + "testing-preview", + "### โญ๏ธ Testing PR preview was unchanged\n\nTesting section", + ], + ]), + ); + + const sections = parseGroupedCommentSections( + "pr-deployment-status", + body, + ); + + expect(sections.get("documentation-preview")).toContain( + "Documentation section", + ); + expect(sections.get("testing-preview")).toContain("Testing section"); + }); +}); diff --git a/.github/actions/devflare-setup-workspace/action.yml b/.github/actions/devflare-setup-workspace/action.yml new file mode 100644 index 0000000..84a60bd --- /dev/null +++ b/.github/actions/devflare-setup-workspace/action.yml @@ -0,0 +1,38 @@ +name: "Devflare Setup Workspace" +description: "Install Bun, warm the Bun cache, and install shared workspace dependencies once per workflow job" +inputs: + bun-version: + description: "Bun version to install" + required: false + default: "1.3.12" + working-directory: + description: "Directory to run the install command from" + required: false + default: "." + install-command: + description: "Dependency installation command to run" + required: false + default: "bun install --frozen-lockfile" +runs: + using: "composite" + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Restore Bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ inputs.bun-version }}-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-${{ inputs.bun-version }}- + + - name: Install shared workspace dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + + ${{ inputs.install-command }} diff --git a/.github/scripts/resolve-deploy-impact.mjs b/.github/scripts/resolve-deploy-impact.mjs new file mode 100644 index 0000000..649aed9 --- /dev/null +++ b/.github/scripts/resolve-deploy-impact.mjs @@ -0,0 +1,488 @@ +import { appendFileSync, readdirSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { join, relative, resolve as resolvePath } from "node:path"; + +const ignoredDirectories = new Set([ + ".devflare", + ".git", + ".svelte-kit", + ".turbo", + ".wrangler", + "coverage", + "dist", + "node_modules", +]); + +const rootDir = process.cwd(); + +export const SHARED_DEPLOY_INFRASTRUCTURE_PATTERNS = [ + ".github/actions/devflare-deploy/**", + ".github/actions/devflare-deploy-impact/**", + ".github/actions/devflare-github-feedback/**", + ".github/actions/devflare-setup-workspace/**", + ".github/scripts/resolve-deploy-impact.mjs", + ".github/scripts/verify-testing-preview-deployment.ts", + ".github/workflows/documentation-production.yml", + ".github/workflows/preview.yml", +]; + +function normalizePath(path) { + return path.replace(/\\+/g, "/").replace(/^\.\//, "").replace(/^\//, ""); +} + +function parseList(value) { + if (!value) { + return []; + } + + return value + .split(/\r?\n|,/) + .map((entry) => normalizePath(entry.trim())) + .filter(Boolean); +} + +function parseArgs(argv) { + const parsed = { + targetPackage: "", + baseRef: "", + headRef: "", + extraPaths: [], + changedFiles: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + const next = argv[index + 1]; + + if (current === "--target-package" && next) { + parsed.targetPackage = next; + index += 1; + continue; + } + + if (current === "--base-ref" && next) { + parsed.baseRef = next; + index += 1; + continue; + } + + if (current === "--head-ref" && next) { + parsed.headRef = next; + index += 1; + continue; + } + + if (current === "--extra-path" && next) { + parsed.extraPaths.push(normalizePath(next)); + index += 1; + continue; + } + + if (current === "--changed-file" && next) { + parsed.changedFiles.push(normalizePath(next)); + index += 1; + } + } + + return parsed; +} + +function writeOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + const normalized = String(value ?? ""); + if (!/[\r\n]/.test(normalized)) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${normalized}\n`); + return; + } + + const delimiter = `DEVFLARE_${ + name.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + }_${Date.now()}`; + appendFileSync( + process.env.GITHUB_OUTPUT, + `${name}<<${delimiter}\n${normalized}\n${delimiter}\n`, + ); +} + +function git(args, options = {}) { + return execFileSync("git", args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trim(); +} + +function isZeroSha(value) { + return /^[0]+$/.test(value); +} + +function isMeaningfulRef(value) { + return Boolean(value) && !isZeroSha(value); +} + +function ensureRefAvailable(ref) { + if (!ref || ref === "HEAD") { + return; + } + + try { + git(["rev-parse", "--verify", `${ref}^{commit}`]); + } catch { + try { + git(["fetch", "--no-tags", "--depth=1", "origin", ref]); + } catch { + // ignore here and let the follow-up verification surface a clear error if the ref is still missing + } + + git(["rev-parse", "--verify", `${ref}^{commit}`]); + } +} + +function escapeRegExp(value) { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function globToRegExp(glob) { + let pattern = ""; + for (let index = 0; index < glob.length; index += 1) { + const current = glob[index]; + const next = glob[index + 1]; + + if (current === "*" && next === "*") { + pattern += ".*"; + index += 1; + continue; + } + + if (current === "*") { + pattern += "[^/]*"; + continue; + } + + if (current === "?") { + pattern += "[^/]"; + continue; + } + + pattern += escapeRegExp(current); + } + + return new RegExp(`^${pattern}$`); +} + +export function matchesAnyPattern(filePath, patterns) { + return patterns.some((pattern) => globToRegExp(pattern).test(filePath)); +} + +export function createGlobalDependencyPatterns(turboConfig) { + return [ + ...new Set( + [ + "package.json", + "turbo.json", + ...(turboConfig.globalDependencies ?? []), + ...SHARED_DEPLOY_INFRASTRUCTURE_PATTERNS, + ].map(normalizePath), + ), + ]; +} + +function readJson(relativePath) { + return JSON.parse(readFileSync(join(rootDir, relativePath), "utf8")); +} + +function discoverWorkspacePackages() { + const packages = new Map(); + + function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (ignoredDirectories.has(entry.name)) { + continue; + } + + walk(join(directory, entry.name)); + continue; + } + + if (entry.name !== "package.json") { + continue; + } + + const absolutePath = join(directory, entry.name); + const relativePath = normalizePath(relative(rootDir, absolutePath)); + const packageDirectory = normalizePath( + relative(rootDir, directory), + ); + + if (relativePath === "package.json") { + continue; + } + + const manifest = JSON.parse(readFileSync(absolutePath, "utf8")); + if (!manifest.name) { + continue; + } + + packages.set(manifest.name, { + name: manifest.name, + directory: packageDirectory, + dependencies: [], + manifest, + }); + } + } + + walk(rootDir); + + for (const pkg of packages.values()) { + const internalDependencies = new Set(); + for ( + const dependencyField of [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ] + ) { + const dependencyMap = pkg.manifest[dependencyField] ?? {}; + for (const dependencyName of Object.keys(dependencyMap)) { + if (packages.has(dependencyName)) { + internalDependencies.add(dependencyName); + } + } + } + + pkg.dependencies = [...internalDependencies]; + } + + return packages; +} + +function resolveComparisonRefs(cliArgs) { + const defaultBranch = process.env.DEVFLARE_DEFAULT_BRANCH?.trim() || "main"; + const eventName = process.env.DEVFLARE_EVENT_NAME?.trim() || ""; + const eventAction = process.env.DEVFLARE_EVENT_ACTION?.trim() || ""; + const pushBefore = process.env.DEVFLARE_PUSH_BEFORE?.trim() || ""; + const pullRequestBaseSha = + process.env.DEVFLARE_PULL_REQUEST_BASE_SHA?.trim() || ""; + const pullRequestHeadSha = + process.env.DEVFLARE_PULL_REQUEST_HEAD_SHA?.trim() || ""; + + const headRef = cliArgs.headRef || pullRequestHeadSha || "HEAD"; + let baseRef = cliArgs.baseRef; + + if (!baseRef) { + if ( + eventName === "pull_request" && eventAction === "synchronize" && + isMeaningfulRef(pushBefore) + ) { + baseRef = pushBefore; + } else if (eventName === "push" && isMeaningfulRef(pushBefore)) { + baseRef = pushBefore; + } else if (isMeaningfulRef(pullRequestBaseSha)) { + baseRef = pullRequestBaseSha; + } + } + + ensureRefAvailable(headRef); + + if (isMeaningfulRef(baseRef)) { + ensureRefAvailable(baseRef); + return { + baseRef, + headRef, + }; + } + + const defaultBranchRef = `origin/${defaultBranch}`; + ensureRefAvailable(defaultBranchRef); + + return { + baseRef: git(["merge-base", headRef, defaultBranchRef]), + headRef, + }; +} + +function resolveChangedFiles(cliArgs, comparison) { + if (cliArgs.changedFiles.length > 0) { + return [...new Set(cliArgs.changedFiles.map(normalizePath))]; + } + + const output = git([ + "diff", + "--name-only", + "--relative", + comparison.baseRef, + comparison.headRef, + ]); + if (!output) { + return []; + } + + return [ + ...new Set( + output.split(/\r?\n/).map((entry) => normalizePath(entry.trim())) + .filter(Boolean), + ), + ]; +} + +function main() { + const cliArgs = parseArgs(process.argv.slice(2)); + const targetPackage = cliArgs.targetPackage || + process.env.DEVFLARE_DEPLOY_TARGET?.trim(); + if (!targetPackage) { + throw new Error( + "Missing target package. Provide --target-package or DEVFLARE_DEPLOY_TARGET.", + ); + } + + const extraPaths = [ + ...new Set([ + ...cliArgs.extraPaths, + ...parseList(process.env.DEVFLARE_EXTRA_PATHS), + ]), + ]; + const rootPackageJson = readJson("package.json"); + const turboConfig = readJson("turbo.json"); + const packages = discoverWorkspacePackages(); + const target = packages.get(targetPackage); + + if (!target) { + throw new Error(`Could not find workspace package "${targetPackage}".`); + } + + const comparison = resolveComparisonRefs(cliArgs); + const changedFiles = resolveChangedFiles(cliArgs, comparison); + const globalPatterns = createGlobalDependencyPatterns(turboConfig); + const globalChangedFiles = changedFiles.filter((filePath) => + matchesAnyPattern(filePath, globalPatterns) + ); + + for (const pkg of packages.values()) { + pkg.changedFiles = changedFiles.filter( + (filePath) => + filePath === pkg.directory || + filePath.startsWith(`${pkg.directory}/`), + ); + } + + const changedWorkspaces = [...packages.values()] + .filter((pkg) => pkg.changedFiles.length > 0) + .map((pkg) => pkg.name) + .sort(); + + const memo = new Map(); + + function evaluatePackage(packageName, stack = new Set()) { + if (memo.has(packageName)) { + return memo.get(packageName); + } + + if (stack.has(packageName)) { + return { + shouldDeploy: false, + reasons: [], + }; + } + + stack.add(packageName); + const pkg = packages.get(packageName); + const reasons = []; + + if (globalChangedFiles.length > 0) { + reasons.push( + `global dependency changed (${ + globalChangedFiles.slice(0, 5).join(", ") + })`, + ); + } + + if (pkg.changedFiles.length > 0) { + reasons.push( + `workspace changed (${ + pkg.changedFiles.slice(0, 5).join(", ") + })`, + ); + } + + const matchingExtraPaths = packageName === targetPackage + ? changedFiles.filter((filePath) => + extraPaths.some( + (extraPath) => + filePath === extraPath || + filePath.startsWith(`${extraPath}/`) || + matchesAnyPattern(filePath, [extraPath]), + ) + ) + : []; + + if (matchingExtraPaths.length > 0) { + reasons.push( + `extra dependency path changed (${ + matchingExtraPaths.slice(0, 5).join(", ") + })`, + ); + } + + for (const dependencyName of pkg.dependencies) { + const dependencyResult = evaluatePackage( + dependencyName, + new Set(stack), + ); + if (dependencyResult.shouldDeploy) { + reasons.push( + `workspace dependency "${dependencyName}" changed`, + ); + } + } + + const result = { + shouldDeploy: reasons.length > 0, + reasons, + }; + + memo.set(packageName, result); + return result; + } + + const evaluation = evaluatePackage(targetPackage); + const reason = evaluation.reasons[0] ?? + "no relevant workspace or global dependency changes detected"; + const result = { + targetPackage, + shouldDeploy: evaluation.shouldDeploy, + reason, + reasons: evaluation.reasons, + comparisonBase: comparison.baseRef, + comparisonHead: comparison.headRef, + changedFiles, + changedWorkspaces, + globalDependencies: globalPatterns, + workspaceDependencies: target.dependencies, + workspaceRoot: target.directory, + rootPackageManager: rootPackageJson.packageManager ?? "", + }; + + writeOutput("should-deploy", evaluation.shouldDeploy ? "true" : "false"); + writeOutput("reason", reason); + writeOutput("comparison-base", comparison.baseRef); + writeOutput("comparison-head", comparison.headRef); + writeOutput("changed-workspaces", changedWorkspaces.join(",")); + writeOutput("changed-files", changedFiles.join("\n")); + + console.log(JSON.stringify(result, null, 2)); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolvePath(process.argv[1]) +) { + main(); +} diff --git a/.github/scripts/verify-testing-preview-deployment.ts b/.github/scripts/verify-testing-preview-deployment.ts new file mode 100644 index 0000000..3a8b377 --- /dev/null +++ b/.github/scripts/verify-testing-preview-deployment.ts @@ -0,0 +1,645 @@ +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + account, + type APIClientOptions, + type WorkerDeploymentInfo +} from '../../packages/devflare/src/cloudflare' +import { getDependencies } from '../../packages/devflare/src/cli/dependencies' +import { + parseWranglerVersionBindings, + type ParsedWranglerBindingRow +} from '../../packages/devflare/src/cli/preview-bindings' +import { + loadConfig, + resolveConfigForEnvironment, + type DevflareConfig +} from '../../packages/devflare/src/config' +import { resolveTestingWorkerNames } from '../../apps/testing/worker-names' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, '..', '..') +const TESTING_DIR = resolve(REPO_ROOT, 'apps', 'testing') +const CLOUDFLARE_API_OPTIONS: APIClientOptions = { + timeout: 10000 +} + +export const DEFAULT_EXPECTED_APP_NAME = 'testing-binding-matrix-preview' +export const DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL = 'preview' + +export const REQUIRED_MAIN_BINDINGS = [ + 'SESSIONS', + 'SESSION_ROOM', + 'COLLABORATION_STATE', + 'CROSS_WORKER_LOCK', + 'AUTH_SERVICE', + 'ADMIN_RPC', + 'SEARCH_SERVICE', + 'DOCUMENT_INDEX', + 'SEARCH_INDEX', + 'APP_ANALYTICS', + 'SEARCH_ANALYTICS', + 'TRANSACTIONAL_EMAIL', + 'SUPPORT_EMAIL', + 'POSTGRES' +] as const + +export interface TestingPreviewVerificationSnapshot { + expectedAppName: string + expectedDeploymentChannel: string + expectedWorkerName: string + expectedAuthWorkerName: string + expectedSearchWorkerName: string + resolvedWorkerName: string + resolvedAppName?: string + resolvedDeploymentChannel?: string + previewUrl?: string + previewStatus?: TestingPreviewStatus + previewStatusError?: string + previewStatusAccessBlocked?: boolean + previewHealth?: PreviewHealthResult + previewHealthError?: string + availableWorkers: string[] + versionId?: string + bindingsInspected: boolean + bindingNames: string[] +} + +export interface TestingPreviewStatus { + appName?: string + deploymentChannel?: string + hasDurableObjectBindings?: boolean + hasServiceBindings?: boolean + hasVectorizeBindings?: boolean + hasAnalyticsBindings?: boolean + hasSendEmailBindings?: boolean + hasHyperdriveBinding?: boolean +} + +function restoreOptionalEnvironmentVariable(name: string, value: string | undefined): void { + if (typeof value === 'undefined') { + delete process.env[name] + return + } + + process.env[name] = value +} + +async function withTemporaryPreviewEnvironment( + previewScope: string, + operation: () => Promise +): Promise { + const originalPreviewBranch = process.env.DEVFLARE_PREVIEW_BRANCH + const originalPreviewIdentifier = process.env.DEVFLARE_PREVIEW_IDENTIFIER + + process.env.DEVFLARE_PREVIEW_BRANCH = previewScope + process.env.DEVFLARE_PREVIEW_IDENTIFIER = previewScope + + try { + return await operation() + } finally { + restoreOptionalEnvironmentVariable('DEVFLARE_PREVIEW_BRANCH', originalPreviewBranch) + restoreOptionalEnvironmentVariable('DEVFLARE_PREVIEW_IDENTIFIER', originalPreviewIdentifier) + } +} + +export async function loadTestingPreviewConfig(previewScope: string): Promise { + return withTemporaryPreviewEnvironment(previewScope, async () => { + const config = await loadConfig({ + cwd: TESTING_DIR + }) + + return resolveConfigForEnvironment(config, 'preview') + }) +} + +function uniqueSorted(values: string[]): string[] { + return Array.from(new Set(values.filter((value) => value.trim().length > 0))).sort( + (left, right) => left.localeCompare(right) + ) +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +function appendPreviewPath(previewUrl: string, pathSuffix: string): string { + return `${previewUrl.replace(/\/+$/g, '')}${pathSuffix}` +} + +function isCloudflareAccessRedirect(response: Response): boolean { + if (response.status < 300 || response.status >= 400) { + return false + } + + const location = response.headers.get('location') + if (!location) { + return false + } + + try { + return new URL(location, 'http://placeholder.invalid').host.includes('cloudflareaccess.com') + } catch { + return false + } +} + +async function readBodyExcerpt(response: Response): Promise { + try { + const text = await response.text() + return text.length > 500 ? `${text.slice(0, 500)}โ€ฆ` : text + } catch { + return '' + } +} + +// When the preview worker sits behind a Cloudflare Access policy, callers +// must present a service-token (CF-Access-Client-Id / CF-Access-Client-Secret). +// Both env vars must be set; partial config is treated as no config. +function cloudflareAccessHeaders(): Record { + const id = process.env.CLOUDFLARE_ACCESS_CLIENT_ID + const secret = process.env.CLOUDFLARE_ACCESS_CLIENT_SECRET + if (!id || !secret) { + return {} + } + return { + 'CF-Access-Client-Id': id, + 'CF-Access-Client-Secret': secret + } +} + +export interface PreviewHealthResult { + ok: boolean + status: number + body: string + redirectedToAccess: boolean + locationHeader?: string +} + +async function loadPreviewHealth( + previewUrl: string, + _attempt: number +): Promise { + const response = await fetch(appendPreviewPath(previewUrl, '/health'), { + redirect: 'manual', + cache: 'no-store', + headers: { + 'cache-control': 'no-store', + ...cloudflareAccessHeaders() + }, + signal: AbortSignal.timeout(15_000) + }) + + const locationHeader = response.headers.get('location') ?? undefined + + if (isCloudflareAccessRedirect(response)) { + const body = await readBodyExcerpt(response) + return { + ok: false, + status: response.status, + body, + redirectedToAccess: true, + locationHeader + } + } + + const body = await readBodyExcerpt(response) + return { + ok: response.ok, + status: response.status, + body, + redirectedToAccess: false, + locationHeader + } +} + +async function loadPreviewStatus(previewUrl: string): Promise { + const response = await fetch(appendPreviewPath(previewUrl, '/status'), { + redirect: 'manual', + headers: { + 'cache-control': 'no-store', + ...cloudflareAccessHeaders() + } + }) + + if (isCloudflareAccessRedirect(response)) { + const locationHeader = response.headers.get('location') ?? '(missing)' + throw new Error( + `Cloudflare Access intercepted ${appendPreviewPath(previewUrl, '/status')} (Location: ${locationHeader}). Cannot read /status.` + ) + } + + if (!response.ok) { + throw new Error(`Preview status endpoint returned ${response.status} ${response.statusText}.`) + } + + const payload = (await response.json()) as Record + + return { + appName: readOptionalString(payload.appName), + deploymentChannel: readOptionalString(payload.deploymentChannel), + hasDurableObjectBindings: readOptionalBoolean(payload.hasDurableObjectBindings), + hasServiceBindings: readOptionalBoolean(payload.hasServiceBindings), + hasVectorizeBindings: readOptionalBoolean(payload.hasVectorizeBindings), + hasAnalyticsBindings: readOptionalBoolean(payload.hasAnalyticsBindings), + hasSendEmailBindings: readOptionalBoolean(payload.hasSendEmailBindings), + hasHyperdriveBinding: readOptionalBoolean(payload.hasHyperdriveBinding) + } +} + +function resolveActiveVersionId(deployments: WorkerDeploymentInfo[]): string | undefined { + const sortedDeployments = [...deployments].sort((left, right) => { + return right.createdOn.getTime() - left.createdOn.getTime() + }) + + for (const deployment of sortedDeployments) { + const version = [...deployment.versions].sort( + (left, right) => right.percentage - left.percentage + )[0] + if (version?.versionId) { + return version.versionId + } + } + + return undefined +} + +async function inspectWorkerVersionBindings(options: { + accountId: string + workerName: string + versionId: string + cwd: string +}): Promise { + const deps = await getDependencies() + const result = await deps.exec.exec( + 'bunx', + ['wrangler', 'versions', 'view', options.versionId, '--name', options.workerName, '--json'], + { + cwd: options.cwd, + env: { + ...process.env, + CLOUDFLARE_ACCOUNT_ID: options.accountId, + FORCE_COLOR: process.env.FORCE_COLOR ?? '0' + } + } + ) + + if (result.exitCode !== 0) { + throw new Error(result.stderr || result.stdout || 'Wrangler versions view failed') + } + + return parseWranglerVersionBindings(result.stdout) +} + +export function collectTestingPreviewVerificationErrors( + snapshot: TestingPreviewVerificationSnapshot +): string[] { + const errors: string[] = [] + const availableWorkers = new Set(snapshot.availableWorkers) + const bindingNames = new Set(snapshot.bindingNames) + const hasVerifiedPreviewUrl = + typeof snapshot.previewUrl === 'string' && snapshot.previewUrl.trim().length > 0 + + if (hasVerifiedPreviewUrl && snapshot.previewHealth) { + if (snapshot.previewHealth.redirectedToAccess) { + if (!snapshot.bindingsInspected) { + errors.push( + `Cloudflare Access intercepted ${snapshot.previewUrl}/health (Location: ${snapshot.previewHealth.locationHeader ?? '(missing)'}). The verifier cannot determine deployment health.` + ) + } + } else if (!snapshot.previewHealth.ok) { + errors.push( + `Preview /health probe at ${snapshot.previewUrl}/health returned ${snapshot.previewHealth.status}. Body excerpt: ${snapshot.previewHealth.body || '(empty)'}` + ) + } + } else if (hasVerifiedPreviewUrl && snapshot.previewHealthError) { + errors.push( + `Preview /health probe at ${snapshot.previewUrl}/health failed: ${snapshot.previewHealthError}` + ) + } + + if (snapshot.resolvedWorkerName !== snapshot.expectedWorkerName) { + errors.push( + `Resolved preview worker name was ${JSON.stringify(snapshot.resolvedWorkerName)} instead of ${JSON.stringify(snapshot.expectedWorkerName)}.` + ) + } + + if (snapshot.resolvedAppName !== snapshot.expectedAppName) { + errors.push( + `Resolved APP_NAME was ${JSON.stringify(snapshot.resolvedAppName)} instead of ${JSON.stringify(snapshot.expectedAppName)}.` + ) + } + + if (snapshot.resolvedDeploymentChannel !== snapshot.expectedDeploymentChannel) { + errors.push( + `Resolved DEPLOYMENT_CHANNEL was ${JSON.stringify(snapshot.resolvedDeploymentChannel)} instead of ${JSON.stringify(snapshot.expectedDeploymentChannel)}.` + ) + } + + if (!availableWorkers.has(snapshot.expectedWorkerName)) { + errors.push( + `Expected deployed preview worker ${JSON.stringify(snapshot.expectedWorkerName)} was not found in the Cloudflare account.` + ) + } + + if (!snapshot.bindingsInspected) { + for (const sidecarWorkerName of [ + snapshot.expectedAuthWorkerName, + snapshot.expectedSearchWorkerName + ]) { + if (!availableWorkers.has(sidecarWorkerName)) { + errors.push( + `Expected preview sidecar worker ${JSON.stringify(sidecarWorkerName)} was not found in the Cloudflare account.` + ) + } + } + } + + if (!snapshot.versionId && !hasVerifiedPreviewUrl) { + errors.push( + `Could not resolve an active deployment version for ${JSON.stringify(snapshot.expectedWorkerName)}.` + ) + } + + const canUseMetadataInsteadOfStatus = + snapshot.bindingsInspected && snapshot.previewStatusAccessBlocked === true + + if (hasVerifiedPreviewUrl && !snapshot.previewStatus && !canUseMetadataInsteadOfStatus) { + errors.push( + snapshot.previewStatusError + ? `Could not load the preview status endpoint from ${JSON.stringify(snapshot.previewUrl)}: ${snapshot.previewStatusError}` + : `Could not load the preview status endpoint from ${JSON.stringify(snapshot.previewUrl)}.` + ) + } + + if (snapshot.previewStatus) { + if (snapshot.previewStatus.appName !== snapshot.expectedAppName) { + errors.push( + `Preview status APP_NAME was ${JSON.stringify(snapshot.previewStatus.appName)} instead of ${JSON.stringify(snapshot.expectedAppName)}.` + ) + } + + if (snapshot.previewStatus.deploymentChannel !== snapshot.expectedDeploymentChannel) { + errors.push( + `Preview status DEPLOYMENT_CHANNEL was ${JSON.stringify(snapshot.previewStatus.deploymentChannel)} instead of ${JSON.stringify(snapshot.expectedDeploymentChannel)}.` + ) + } + + if (snapshot.previewStatus.hasDurableObjectBindings !== true) { + errors.push('Preview status did not confirm durable object bindings.') + } + + if (snapshot.previewStatus.hasServiceBindings !== true) { + errors.push('Preview status did not confirm service bindings.') + } + + if (snapshot.previewStatus.hasVectorizeBindings !== true) { + errors.push('Preview status did not confirm vectorize bindings.') + } + + if (snapshot.previewStatus.hasAnalyticsBindings !== true) { + errors.push('Preview status did not confirm analytics bindings.') + } + + if (snapshot.previewStatus.hasSendEmailBindings !== true) { + errors.push('Preview status did not confirm send-email bindings.') + } + + if (snapshot.previewStatus.hasHyperdriveBinding !== true) { + errors.push('Preview status did not confirm the Hyperdrive binding.') + } + } + + if (snapshot.bindingsInspected) { + for (const bindingName of REQUIRED_MAIN_BINDINGS) { + if (!bindingNames.has(bindingName)) { + errors.push( + `Expected binding ${JSON.stringify(bindingName)} was missing from the deployed preview Worker version.` + ) + } + } + } + + return errors +} + +async function loadVerificationSnapshot( + previewScope: string, + accountId: string, + requestedVersionId?: string +): Promise<{ + snapshot: TestingPreviewVerificationSnapshot + bindingRows: ParsedWranglerBindingRow[] + availableTestingWorkers: string[] +}> { + const workerNames = resolveTestingWorkerNames(previewScope) + const config = await loadTestingPreviewConfig(previewScope) + const vars = (config.vars ?? {}) as Record + const previewUrl = process.env.TESTING_DEPLOY_PREVIEW_URL?.trim() || undefined + const liveWorkers = await account.workers(accountId, CLOUDFLARE_API_OPTIONS) + const availableWorkers = uniqueSorted(liveWorkers.map((worker) => worker.name)) + const availableWorkerSet = new Set(availableWorkers) + let versionId = requestedVersionId?.trim() || undefined + let bindingRows: ParsedWranglerBindingRow[] = [] + let previewStatus: TestingPreviewStatus | undefined + let previewStatusError: string | undefined + let previewStatusAccessBlocked = false + let previewHealth: PreviewHealthResult | undefined + let previewHealthError: string | undefined + + if (previewUrl) { + try { + previewHealth = await loadPreviewHealth(previewUrl, 1) + } catch (error) { + previewHealthError = error instanceof Error ? error.message : String(error) + } + + try { + previewStatus = await loadPreviewStatus(previewUrl) + } catch (error) { + previewStatusError = error instanceof Error ? error.message : String(error) + previewStatusAccessBlocked = previewStatusError.includes('Cloudflare Access intercepted ') + } + } + + if (!versionId && availableWorkerSet.has(config.name)) { + const deployments = await account.workerDeployments( + accountId, + config.name, + CLOUDFLARE_API_OPTIONS + ) + versionId = resolveActiveVersionId(deployments) + } + + if (versionId) { + bindingRows = await inspectWorkerVersionBindings({ + accountId, + workerName: config.name, + versionId, + cwd: TESTING_DIR + }) + } + + return { + snapshot: { + expectedAppName: process.env.TESTING_EXPECTED_APP_NAME?.trim() || DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: + process.env.TESTING_EXPECTED_DEPLOYMENT_CHANNEL?.trim() || + DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: workerNames.mainWorkerName, + expectedAuthWorkerName: workerNames.authServiceName, + expectedSearchWorkerName: workerNames.searchServiceName, + resolvedWorkerName: config.name, + resolvedAppName: readOptionalString(vars.APP_NAME), + resolvedDeploymentChannel: readOptionalString(vars.DEPLOYMENT_CHANNEL), + previewUrl, + previewStatus, + previewStatusError, + previewStatusAccessBlocked, + previewHealth, + previewHealthError, + availableWorkers, + versionId, + bindingsInspected: versionId !== undefined, + bindingNames: uniqueSorted(bindingRows.map((row) => row.bindingName)) + }, + bindingRows, + availableTestingWorkers: availableWorkers.filter((workerName) => + workerName.startsWith('devflare-testing-') + ) + } +} + +function formatBindingRows(rows: ParsedWranglerBindingRow[]): string { + if (rows.length === 0) { + return '(none)' + } + + return rows.map((row) => `${row.type}: ${row.bindingName} -> ${row.resource}`).join('\n') +} + +function createDiagnosticsMessage(input: { + snapshot: TestingPreviewVerificationSnapshot + bindingRows: ParsedWranglerBindingRow[] + availableTestingWorkers: string[] + errors: string[] +}): string { + const details = [ + 'Testing preview verification failed.', + ...input.errors.map((error) => `- ${error}`), + '', + `Expected preview worker: ${input.snapshot.expectedWorkerName}`, + `Expected auth worker: ${input.snapshot.expectedAuthWorkerName}`, + `Expected search worker: ${input.snapshot.expectedSearchWorkerName}`, + `Resolved preview worker: ${input.snapshot.resolvedWorkerName}`, + `Resolved APP_NAME: ${JSON.stringify(input.snapshot.resolvedAppName)}`, + `Resolved DEPLOYMENT_CHANNEL: ${JSON.stringify(input.snapshot.resolvedDeploymentChannel)}`, + `Deploy preview URL: ${input.snapshot.previewUrl ?? 'not provided'}`, + `Preview status APP_NAME: ${JSON.stringify(input.snapshot.previewStatus?.appName)}`, + `Preview status error: ${input.snapshot.previewStatusError ?? 'none'}`, + `Preview status access blocked: ${String(input.snapshot.previewStatusAccessBlocked)}`, + `Preview status DEPLOYMENT_CHANNEL: ${JSON.stringify(input.snapshot.previewStatus?.deploymentChannel)}`, + `Preview status service bindings: ${String(input.snapshot.previewStatus?.hasServiceBindings)}`, + `Preview status durable objects: ${String(input.snapshot.previewStatus?.hasDurableObjectBindings)}`, + `Preview status vectorize: ${String(input.snapshot.previewStatus?.hasVectorizeBindings)}`, + `Preview status analytics: ${String(input.snapshot.previewStatus?.hasAnalyticsBindings)}`, + `Preview status send email: ${String(input.snapshot.previewStatus?.hasSendEmailBindings)}`, + `Preview status hyperdrive: ${String(input.snapshot.previewStatus?.hasHyperdriveBinding)}`, + `Active preview version: ${input.snapshot.versionId ?? 'not found'}`, + `Binding inspection: ${input.snapshot.bindingsInspected ? 'completed via wrangler versions view' : input.snapshot.previewUrl ? 'skipped because Cloudflare did not expose preview version metadata after a successful named preview deploy' : 'not available'}`, + `Testing workers in account: ${input.availableTestingWorkers.join(', ') || '(none)'}`, + `Deployed main-worker binding names: ${input.snapshot.bindingNames.join(', ') || '(none)'}`, + 'Deployed main-worker binding rows:', + formatBindingRows(input.bindingRows) + ] + + return details.join('\n') +} + +async function runVerification(): Promise { + const previewScope = + process.argv[2]?.trim() || + process.env.DEVFLARE_PREVIEW_BRANCH?.trim() || + process.env.DEVFLARE_PREVIEW_IDENTIFIER?.trim() + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + const requestedVersionId = + process.argv[3]?.trim() || process.env.TESTING_DEPLOY_VERSION_ID?.trim() + const attempts = Number(process.env.TESTING_VERIFICATION_ATTEMPTS ?? '5') + const delayMs = Number(process.env.TESTING_VERIFICATION_DELAY_MS ?? '3000') + + if (!previewScope) { + throw new Error( + 'Provide a preview scope argument or set DEVFLARE_PREVIEW_BRANCH / DEVFLARE_PREVIEW_IDENTIFIER.' + ) + } + + if (!accountId) { + throw new Error( + 'CLOUDFLARE_ACCOUNT_ID must be set before verifying the testing preview deployment.' + ) + } + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const { snapshot, bindingRows, availableTestingWorkers } = await loadVerificationSnapshot( + previewScope, + accountId, + requestedVersionId + ) + const errors = collectTestingPreviewVerificationErrors(snapshot) + + if (errors.length > 0) { + throw new Error( + createDiagnosticsMessage({ + snapshot, + bindingRows, + availableTestingWorkers, + errors + }) + ) + } + + if (!snapshot.bindingsInspected && snapshot.previewUrl) { + console.warn( + `Cloudflare did not expose preview version metadata for ${JSON.stringify(snapshot.expectedWorkerName)}; verified the live preview status endpoint plus expected preview workers instead.` + ) + } + + if (snapshot.bindingsInspected && snapshot.previewStatusAccessBlocked) { + console.warn( + `Live /status was blocked by Cloudflare Access for ${JSON.stringify(snapshot.expectedWorkerName)}; verified deployment settings and bindings through Wrangler metadata instead.` + ) + } + + console.log(`Verified testing preview scope ${JSON.stringify(previewScope)}.`) + console.log( + `Verified main worker ${snapshot.expectedWorkerName} version ${snapshot.versionId ?? 'not exposed by Cloudflare'}.` + ) + console.log(`Verified bindings: ${REQUIRED_MAIN_BINDINGS.join(', ')}.`) + return + } catch (error) { + if (attempt >= attempts) { + throw error + } + + const message = error instanceof Error ? error.message : String(error) + console.error( + `Testing preview verification attempt ${attempt}/${attempts} failed; retrying in ${delayMs}ms...` + ) + console.error(message) + await Bun.sleep(delayMs) + } + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + runVerification().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/.github/workflow-examples/branch-preview-cleanup.example.yml b/.github/workflow-examples/branch-preview-cleanup.example.yml new file mode 100644 index 0000000..381803d --- /dev/null +++ b/.github/workflow-examples/branch-preview-cleanup.example.yml @@ -0,0 +1,63 @@ +name: Example Branch Preview Cleanup + +on: + delete: + workflow_dispatch: + inputs: + branch: + description: Branch name to retire manually + required: true + type: string + +permissions: + contents: read + deployments: write + +jobs: + cleanup-preview: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.ref_type == 'branch' }} + runs-on: ubuntu-latest + env: + PREVIEW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.event.ref }} + FEEDBACK_TITLE: Documentation preview + FEEDBACK_KEY: documentation-preview + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile + + - name: Clean up preview scope + shell: bash + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + bunx --bun devflare previews cleanup \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --scope "$PREVIEW_BRANCH" \ + --apply + + - name: Mark GitHub deployment feedback inactive + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: cleanup + status: inactive + title: ${{ env.FEEDBACK_TITLE }} + comment-key: ${{ env.FEEDBACK_KEY }} + deployment-kind: preview + ref-name: ${{ env.PREVIEW_BRANCH }} + environment: documentation preview / ${{ env.PREVIEW_BRANCH }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + summary: The branch was deleted, so Devflare cleaned up the preview scope and marked the related GitHub deployment inactive. diff --git a/.github/workflows/documentation-production.yml b/.github/workflows/documentation-production.yml new file mode 100644 index 0000000..f5ec211 --- /dev/null +++ b/.github/workflows/documentation-production.yml @@ -0,0 +1,196 @@ +name: Documentation Production + +env: + DOCUMENTATION_PRODUCTION_URL: https://devflare-docs.refz.workers.dev + +on: + push: + paths: + - "apps/documentation/**" + - "packages/devflare/**" + - "package.json" + - "bun.lock" + - "turbo.json" + - ".github/actions/devflare-deploy/**" + - ".github/actions/devflare-deploy-impact/**" + - ".github/actions/devflare-github-feedback/**" + - ".github/actions/devflare-setup-workspace/**" + - ".github/scripts/resolve-deploy-impact.mjs" + - ".github/workflows/documentation-production.yml" + workflow_dispatch: + +permissions: + contents: read + deployments: write + +concurrency: + group: documentation-production + cancel-in-progress: true + +jobs: + deploy-production: + if: ${{ github.event_name == 'workflow_dispatch' || (startsWith(github.ref, 'refs/heads/') && github.ref_name == github.event.repository.default_branch) }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Resolve documentation deploy impact + id: impact + uses: ./.github/actions/devflare-deploy-impact + with: + target-package: documentation + default-branch: ${{ github.event.repository.default_branch }} + event-name: ${{ github.event_name }} + event-action: "" + push-before: ${{ github.event.before || '' }} + pull-request-base-sha: "" + pull-request-head-sha: "" + + - name: Deploy documentation production + id: deploy + if: ${{ steps.impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/documentation + install-working-directory: . + deploy-command: bun run deploy -- + production: "true" + deploy-message: Documentation production ${{ github.sha }} (run ${{ github.run_id }}) + deploy-tag: documentation-production-${{ github.run_id }} + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify live documentation production content + id: verify-live + if: ${{ steps.deploy.outputs.status == 'success' && steps.deploy.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + EXPECTED_BUILD_SHA: ${{ github.sha }} + DOCUMENTATION_LIVE_URL: ${{ steps.deploy.outputs.preview-url || env.DOCUMENTATION_PRODUCTION_URL }} + run: | + set -euo pipefail + + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if payload="$(curl --fail --silent --show-error --location "${DOCUMENTATION_LIVE_URL}/build.json?attempt=${attempt}")"; then + if printf '%s' "$payload" | grep -Fq "$EXPECTED_BUILD_SHA"; then + echo 'verified=true' >> "$GITHUB_OUTPUT" + echo "Verified live production build metadata exposes build SHA ${EXPECTED_BUILD_SHA}." + exit 0 + fi + fi + + if [ "$attempt" -lt 10 ]; then + echo "Live production build metadata does not show build SHA ${EXPECTED_BUILD_SHA} yet (attempt ${attempt}/10). Retrying..." + sleep 6 + fi + done + + echo 'verified=false' >> "$GITHUB_OUTPUT" + echo "Live production build metadata did not contain build SHA ${EXPECTED_BUILD_SHA} after 10 attempts." >&2 + exit 1 + + - name: Publish production deployment feedback + if: ${{ steps.impact.outputs.should-deploy == 'true' && always() }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: report + status: ${{ steps.deploy.outputs.status == 'success' && steps.verify-live.outcome != 'failure' && 'success' || 'failure' }} + title: Documentation production + deployment-kind: production + ref-name: ${{ github.ref_name }} + sha: ${{ github.sha }} + environment: documentation production + environment-url: ${{ steps.deploy.outputs.preview-url || env.DOCUMENTATION_PRODUCTION_URL }} + production-url: ${{ steps.deploy.outputs.preview-url || env.DOCUMENTATION_PRODUCTION_URL }} + version-id: ${{ steps.deploy.outputs.version-id }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + log-excerpt: ${{ steps.deploy.outputs.log-excerpt }} + summary: GitHub now records the production URL and deployment status directly on the branch ref through the Deployments API. + + - name: Summarize production deployment + if: ${{ always() }} + shell: bash + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ### Documentation production workflow + + - Deploy target: explicit production via `--prod` + - Impact decision: `${{ steps.impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` + - Impact reason: ${{ steps.impact.outputs.reason }} + EOF + + if [ "${{ steps.impact.outputs.should-deploy }}" != 'true' ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Deployment verification: skipped because the documentation workspace and its dependencies were unaffected + - GitHub feedback: skipped because no new deployment was needed + - Final status: `skipped` + - Production URL: ${{ env.DOCUMENTATION_PRODUCTION_URL }} + EOF + else + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Deployment verification: handled by the deploy action via Cloudflare control-plane checks + - Live URL verification: ${{ steps.verify-live.outcome == 'success' && 'current build SHA observed on production' || (steps.verify-live.outcome == 'failure' && 'expected build SHA not visible on production' || 'skipped') }} + - GitHub feedback: production deployment status published + - Final status: `${{ steps.deploy.outputs.status == 'success' && steps.verify-live.outcome != 'failure' && 'success' || 'failure' }}` + - Production URL: ${{ steps.deploy.outputs.preview-url || env.DOCUMENTATION_PRODUCTION_URL }} + EOF + + if [ -n "${{ steps.deploy.outputs.failure-stage }}" ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Failure stage: `${{ steps.deploy.outputs.failure-stage }}` + EOF + fi + + if [ -n "${{ steps.deploy.outputs.exit-code }}" ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Exit code: `${{ steps.deploy.outputs.exit-code }}` + EOF + fi + + if [ -n "${{ steps.deploy.outputs.version-id }}" ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Version ID: `${{ steps.deploy.outputs.version-id }}` + EOF + fi + fi + + - name: Fail when documentation production deploy did not succeed + if: ${{ steps.impact.outputs.should-deploy == 'true' && (steps.deploy.outputs.status != 'success' || steps.deploy.outcome == 'failure' || steps.verify-live.outcome == 'failure') }} + shell: bash + env: + DEVFLARE_FAILURE_STAGE: ${{ steps.deploy.outputs.failure-stage }} + DEVFLARE_DEPLOY_EXIT_CODE: ${{ steps.deploy.outputs.exit-code }} + DEVFLARE_DEPLOY_VERSION_ID: ${{ steps.deploy.outputs.version-id }} + DEVFLARE_DEPLOY_LOG_EXCERPT: ${{ steps.deploy.outputs.log-excerpt }} + DOCUMENTATION_LIVE_VERIFY_OUTCOME: ${{ steps.verify-live.outcome }} + DOCUMENTATION_EXPECTED_BUILD_SHA: ${{ github.sha }} + DOCUMENTATION_LIVE_URL: ${{ steps.deploy.outputs.preview-url || env.DOCUMENTATION_PRODUCTION_URL }} + run: | + echo 'Documentation production deployment failed.' >&2 + if [ -n "$DEVFLARE_FAILURE_STAGE" ]; then + echo "Failure stage: $DEVFLARE_FAILURE_STAGE" >&2 + fi + if [ -n "$DEVFLARE_DEPLOY_EXIT_CODE" ]; then + echo "Devflare exit code: $DEVFLARE_DEPLOY_EXIT_CODE" >&2 + fi + if [ -n "$DEVFLARE_DEPLOY_VERSION_ID" ]; then + echo "Last version ID: $DEVFLARE_DEPLOY_VERSION_ID" >&2 + fi + if [ "$DOCUMENTATION_LIVE_VERIFY_OUTCOME" = 'failure' ]; then + echo "Live production build metadata at $DOCUMENTATION_LIVE_URL/build.json did not expose build SHA $DOCUMENTATION_EXPECTED_BUILD_SHA after deploy verification retries." >&2 + fi + if [ -n "$DEVFLARE_DEPLOY_LOG_EXCERPT" ]; then + echo '' >&2 + echo 'Last deploy log excerpt:' >&2 + printf '%s\n' "$DEVFLARE_DEPLOY_LOG_EXCERPT" >&2 + else + echo 'No deploy log excerpt was captured by the deploy action.' >&2 + fi + exit 1 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..4257228 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,957 @@ +name: Preview + +env: + DOCUMENTATION_PRODUCTION_URL: https://devflare-docs.refz.workers.dev + TESTING_EXPECTED_APP_NAME: testing-binding-matrix-preview + TESTING_EXPECTED_DEPLOYMENT_CHANNEL: preview + +on: + push: + paths: + - 'apps/documentation/**' + - 'apps/testing/**' + - 'packages/devflare/**' + - 'package.json' + - 'bun.lock' + - 'turbo.json' + - '.github/actions/devflare-deploy/**' + - '.github/actions/devflare-deploy-impact/**' + - '.github/actions/devflare-github-feedback/**' + - '.github/actions/devflare-setup-workspace/**' + - '.github/scripts/resolve-deploy-impact.mjs' + - '.github/scripts/verify-testing-preview-deployment.ts' + - '.github/workflows/preview.yml' + pull_request: + types: [opened, reopened, ready_for_review, closed] + paths: + - 'apps/documentation/**' + - 'apps/testing/**' + - 'packages/devflare/**' + - 'package.json' + - 'bun.lock' + - 'turbo.json' + - '.github/actions/devflare-deploy/**' + - '.github/actions/devflare-deploy-impact/**' + - '.github/actions/devflare-github-feedback/**' + - '.github/actions/devflare-setup-workspace/**' + - '.github/scripts/resolve-deploy-impact.mjs' + - '.github/scripts/verify-testing-preview-deployment.ts' + - '.github/workflows/preview.yml' + delete: + workflow_dispatch: + inputs: + branch: + description: Branch name to clean up manually + required: true + type: string + project: + description: Which preview family to clean up + required: true + default: all + type: choice + options: + - all + - documentation + - testing + +permissions: + contents: read + deployments: write + issues: write + pull-requests: write + +jobs: + resolve-context: + name: Resolve preview context + runs-on: ubuntu-latest + outputs: + default-branch: ${{ steps.resolve.outputs.default-branch }} + source-branch: ${{ steps.resolve.outputs.source-branch }} + checkout-ref: ${{ steps.resolve.outputs.checkout-ref }} + pr-number: ${{ steps.resolve.outputs.pr-number }} + branch-preview-enabled: ${{ steps.resolve.outputs.branch-preview-enabled }} + pr-preview-enabled: ${{ steps.resolve.outputs.pr-preview-enabled }} + branch-cleanup-enabled: ${{ steps.resolve.outputs.branch-cleanup-enabled }} + pr-cleanup-enabled: ${{ steps.resolve.outputs.pr-cleanup-enabled }} + branch-preview-scope: ${{ steps.resolve.outputs.branch-preview-scope }} + pr-preview-scope: ${{ steps.resolve.outputs.pr-preview-scope }} + cleanup-project: ${{ steps.resolve.outputs.cleanup-project }} + steps: + - name: Resolve preview targets + id: resolve + uses: actions/github-script@v8 + with: + github-token: ${{ github.token }} + script: | + const defaultBranch = context.payload.repository?.default_branch ?? '' + const eventName = context.eventName + const action = context.payload.action ?? '' + const manualBranch = (context.payload.inputs?.branch ?? '').trim() + const manualProject = (context.payload.inputs?.project ?? 'all').trim() || 'all' + let sourceBranch = '' + let checkoutRef = context.sha + let prNumber = '' + let branchPreviewEnabled = false + let prPreviewEnabled = false + let branchCleanupEnabled = false + let prCleanupEnabled = false + + if (eventName === 'push' && context.ref.startsWith('refs/heads/')) { + sourceBranch = context.ref.replace(/^refs\/heads\//, '') + checkoutRef = context.sha + + if (sourceBranch && sourceBranch !== defaultBranch) { + branchPreviewEnabled = true + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${sourceBranch}`, + per_page: 10 + }) + const matchingPr = pulls.find((pull) => pull.base?.ref === defaultBranch && pull.head?.repo?.fork === false) + if (matchingPr?.number) { + prNumber = String(matchingPr.number) + prPreviewEnabled = true + } + } + } else if (eventName === 'pull_request') { + const pullRequest = context.payload.pull_request + sourceBranch = pullRequest?.head?.ref ?? '' + checkoutRef = pullRequest?.head?.sha ?? context.sha + const targetsDefaultBranch = pullRequest?.base?.ref === defaultBranch + const sameRepositoryBranch = pullRequest?.head?.repo?.fork === false + + if (targetsDefaultBranch && sameRepositoryBranch) { + prNumber = String(pullRequest?.number ?? '') + if (action === 'closed') { + prCleanupEnabled = true + } else if (['opened', 'reopened', 'ready_for_review'].includes(action)) { + prPreviewEnabled = true + } + } + } else if (eventName === 'delete' && context.payload.ref_type === 'branch') { + sourceBranch = context.payload.ref ?? '' + branchCleanupEnabled = Boolean(sourceBranch) && sourceBranch !== defaultBranch + } else if (eventName === 'workflow_dispatch') { + sourceBranch = manualBranch + branchCleanupEnabled = Boolean(sourceBranch) && sourceBranch !== defaultBranch + } + + const branchPreviewScope = sourceBranch + const prPreviewScope = prNumber ? `pr-${prNumber}` : '' + + core.setOutput('default-branch', defaultBranch) + core.setOutput('source-branch', sourceBranch) + core.setOutput('checkout-ref', checkoutRef) + core.setOutput('pr-number', prNumber) + core.setOutput('branch-preview-enabled', String(branchPreviewEnabled)) + core.setOutput('pr-preview-enabled', String(prPreviewEnabled)) + core.setOutput('branch-cleanup-enabled', String(branchCleanupEnabled)) + core.setOutput('pr-cleanup-enabled', String(prCleanupEnabled)) + core.setOutput('branch-preview-scope', branchPreviewScope) + core.setOutput('pr-preview-scope', prPreviewScope) + core.setOutput('cleanup-project', manualProject) + + documentation-preview: + name: Documentation preview + needs: resolve-context + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' || needs.resolve-context.outputs.pr-preview-enabled == 'true' }} + runs-on: ubuntu-latest + concurrency: + group: preview-documentation-${{ needs.resolve-context.outputs.source-branch || needs.resolve-context.outputs.pr-number || github.run_id }} + cancel-in-progress: true + steps: + - name: Checkout preview source + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.resolve-context.outputs.checkout-ref }} + + - name: Setup shared workspace + uses: ./.github/actions/devflare-setup-workspace + + - name: Resolve documentation preview impact + id: impact + uses: ./.github/actions/devflare-deploy-impact + with: + target-package: documentation + default-branch: ${{ needs.resolve-context.outputs.default-branch }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action || '' }} + push-before: ${{ github.event.before || '' }} + pull-request-base-sha: ${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: ${{ github.event.pull_request.head.sha || '' }} + + - name: Deploy documentation branch preview + id: branch-deploy + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/documentation + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + deploy-command: bun run deploy -- + preview-scope: ${{ needs.resolve-context.outputs.branch-preview-scope }} + verify-deployment: 'false' + deploy-message: Documentation branch preview ${{ github.sha }} (run ${{ github.run_id }}) + deploy-tag: documentation-branch-preview-${{ github.run_id }} + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Validate documentation branch preview target + id: branch-validate + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' && steps.branch-deploy.outputs.status == 'success' && always() }} + continue-on-error: true + shell: bash + env: + DEVFLARE_PREVIEW_URL: ${{ steps.branch-deploy.outputs.preview-url }} + DEVFLARE_PRODUCTION_URL: ${{ env.DOCUMENTATION_PRODUCTION_URL }} + run: | + set -euo pipefail + + if [ -z "$DEVFLARE_PREVIEW_URL" ]; then + echo 'Expected documentation branch preview deployment to produce a preview URL.' >&2 + exit 1 + fi + + if [ "$DEVFLARE_PREVIEW_URL" = "$DEVFLARE_PRODUCTION_URL" ]; then + echo "Documentation branch preview resolved to the production URL: $DEVFLARE_PREVIEW_URL" >&2 + exit 1 + fi + + - name: Verify documentation branch preview content + id: branch-content + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' && steps.branch-deploy.outputs.status == 'success' && always() }} + continue-on-error: true + shell: bash + env: + DOCUMENTATION_PREVIEW_URL: ${{ steps.branch-deploy.outputs.preview-url }} + EXPECTED_BUILD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if payload="$(curl --fail --silent --show-error --location "${DOCUMENTATION_PREVIEW_URL}/build.json?attempt=${attempt}")"; then + if printf '%s' "$payload" | grep -Fq "$EXPECTED_BUILD_SHA"; then + echo 'verified=true' >> "$GITHUB_OUTPUT" + echo "Verified documentation branch preview exposes build SHA ${EXPECTED_BUILD_SHA}." + exit 0 + fi + fi + + if [ "$attempt" -lt 10 ]; then + echo "Documentation branch preview does not expose build SHA ${EXPECTED_BUILD_SHA} yet (attempt ${attempt}/10). Retrying..." + sleep 6 + fi + done + + echo 'verified=false' >> "$GITHUB_OUTPUT" + echo "Documentation branch preview did not expose build SHA ${EXPECTED_BUILD_SHA} after 10 attempts." >&2 + exit 1 + + - name: Publish documentation branch preview feedback + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' && always() }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: report + status: ${{ steps.branch-deploy.outputs.status == 'success' && steps.branch-validate.outcome != 'failure' && steps.branch-content.outcome != 'failure' && 'success' || 'failure' }} + title: Documentation branch preview + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.branch-preview-scope }} + sha: ${{ github.sha }} + environment: documentation branch preview / ${{ needs.resolve-context.outputs.branch-preview-scope }} + environment-url: ${{ steps.branch-deploy.outputs.preview-url }} + preview-url: ${{ steps.branch-deploy.outputs.preview-url }} + version-id: ${{ steps.branch-deploy.outputs.version-id }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + log-excerpt: ${{ steps.branch-deploy.outputs.log-excerpt }} + summary: This shared preview workflow keeps the named documentation preview scope updated on qualifying pushes. + + - name: Deploy documentation PR preview + id: pr-deploy + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/documentation + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + deploy-command: bun run deploy -- + preview-scope: ${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + deploy-message: Documentation PR preview ${{ needs.resolve-context.outputs.checkout-ref }} (run ${{ github.run_id }}) + deploy-tag: documentation-pr-preview-${{ github.run_id }} + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Validate documentation PR preview target + id: pr-validate + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' && steps.pr-deploy.outputs.status == 'success' && always() }} + continue-on-error: true + shell: bash + env: + DEVFLARE_PREVIEW_URL: ${{ steps.pr-deploy.outputs.preview-url }} + DEVFLARE_EXPECTED_PREVIEW_WORKER: devflare-docs-${{ needs.resolve-context.outputs.pr-preview-scope }} + DEVFLARE_PRODUCTION_URL: ${{ env.DOCUMENTATION_PRODUCTION_URL }} + run: | + set -euo pipefail + + if [ -z "$DEVFLARE_PREVIEW_URL" ]; then + echo 'Expected documentation PR preview deployment to produce a preview URL.' >&2 + exit 1 + fi + + if [ "$DEVFLARE_PREVIEW_URL" = "$DEVFLARE_PRODUCTION_URL" ]; then + echo "Documentation PR preview resolved to the production URL: $DEVFLARE_PREVIEW_URL" >&2 + exit 1 + fi + + if [[ "$DEVFLARE_PREVIEW_URL" != *"$DEVFLARE_EXPECTED_PREVIEW_WORKER"* ]]; then + echo "Documentation PR preview resolved to $DEVFLARE_PREVIEW_URL, which does not contain the expected preview worker name $DEVFLARE_EXPECTED_PREVIEW_WORKER." >&2 + exit 1 + fi + + - name: Verify documentation PR preview content + id: pr-content + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.impact.outputs.should-deploy == 'true' && steps.pr-deploy.outputs.status == 'success' && always() }} + continue-on-error: true + shell: bash + env: + DOCUMENTATION_PREVIEW_URL: ${{ steps.pr-deploy.outputs.preview-url }} + EXPECTED_BUILD_SHA: ${{ needs.resolve-context.outputs.checkout-ref }} + run: | + set -euo pipefail + + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if payload="$(curl --fail --silent --show-error --location "${DOCUMENTATION_PREVIEW_URL}/build.json?attempt=${attempt}")"; then + if printf '%s' "$payload" | grep -Fq "$EXPECTED_BUILD_SHA"; then + echo 'verified=true' >> "$GITHUB_OUTPUT" + echo "Verified documentation PR preview exposes build SHA ${EXPECTED_BUILD_SHA}." + exit 0 + fi + fi + + if [ "$attempt" -lt 10 ]; then + echo "Documentation PR preview does not expose build SHA ${EXPECTED_BUILD_SHA} yet (attempt ${attempt}/10). Retrying..." + sleep 6 + fi + done + + echo 'verified=false' >> "$GITHUB_OUTPUT" + echo "Documentation PR preview did not expose build SHA ${EXPECTED_BUILD_SHA} after 10 attempts." >&2 + exit 1 + + - name: Publish documentation PR preview feedback + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && always() }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: comment + operation: report + status: ${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-deploy.outputs.status == 'success' && steps.pr-validate.outcome != 'failure' && steps.pr-content.outcome != 'failure' && 'success' || 'failure') }} + title: Documentation PR preview + comment-key: pr-deployment-status + comment-section-key: documentation-preview + comment-group-title: Pull request deployment status + comment-group-summary: This single comment tracks the latest documentation and testing preview results for this pull request and is updated in place by the shared preview workflow. + pr-number: ${{ needs.resolve-context.outputs.pr-number }} + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.source-branch }} + sha: ${{ needs.resolve-context.outputs.checkout-ref }} + preview-url: ${{ steps.pr-deploy.outputs.preview-url }} + version-id: ${{ steps.pr-deploy.outputs.version-id }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + log-excerpt: ${{ steps.pr-deploy.outputs.log-excerpt }} + summary: ${{ steps.impact.outputs.should-deploy != 'true' && 'No documentation preview redeploy was needed for this run, so the existing PR-scoped preview remains in place.' || 'This pull request gets a stable PR-scoped documentation preview that the shared preview workflow updates in place on every relevant branch push or PR lifecycle event.' }} + details-markdown: | + - Impact decision: `${{ steps.impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` + - Impact reason: `${{ steps.impact.outputs.reason }}` + - Preview target verification: `${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-validate.outcome == 'success' && 'passed' || 'failed') }}` + - Preview content verification: `${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-content.outcome == 'success' && 'passed' || 'failed') }}` + - Final status: `${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-deploy.outputs.status == 'success' && steps.pr-validate.outcome != 'failure' && steps.pr-content.outcome != 'failure' && 'success' || 'failure') }}` + - Preview strategy: branch pushes build the workspace once per job, then update both branch and PR targets when the branch already belongs to an open pull request. + + - name: Summarize documentation preview + if: ${{ always() }} + shell: bash + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ### Documentation preview + + - Impact decision: `${{ steps.impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` + - Impact reason: ${{ steps.impact.outputs.reason }} + - Branch target: `${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && needs.resolve-context.outputs.branch-preview-scope || 'disabled' }}` + - PR target: `${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && needs.resolve-context.outputs.pr-preview-scope || 'disabled' }}` + EOF + + if [ "${{ needs.resolve-context.outputs.branch-preview-enabled }}" = 'true' ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Branch preview status: `${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.branch-deploy.outputs.status == 'success' && steps.branch-validate.outcome != 'failure' && steps.branch-content.outcome != 'failure' && 'success' || 'failure') }}` + EOF + fi + + if [ "${{ needs.resolve-context.outputs.pr-preview-enabled }}" = 'true' ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - PR preview status: `${{ steps.impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-deploy.outputs.status == 'success' && steps.pr-validate.outcome != 'failure' && steps.pr-content.outcome != 'failure' && 'success' || 'failure') }}` + EOF + fi + + - name: Fail when documentation preview deploy did not succeed + if: ${{ always() && steps.impact.outputs.should-deploy == 'true' && ((needs.resolve-context.outputs.branch-preview-enabled == 'true' && (steps.branch-deploy.outputs.status != 'success' || steps.branch-deploy.outcome == 'failure' || steps.branch-validate.outcome == 'failure' || steps.branch-content.outcome == 'failure')) || (needs.resolve-context.outputs.pr-preview-enabled == 'true' && (steps.pr-deploy.outputs.status != 'success' || steps.pr-deploy.outcome == 'failure' || steps.pr-validate.outcome == 'failure' || steps.pr-content.outcome == 'failure'))) }} + shell: bash + run: | + echo 'Documentation preview deployment failed.' >&2 + exit 1 + + documentation-cleanup: + name: Documentation cleanup + needs: resolve-context + if: ${{ (needs.resolve-context.outputs.branch-cleanup-enabled == 'true' && (needs.resolve-context.outputs.cleanup-project == 'all' || needs.resolve-context.outputs.cleanup-project == 'documentation')) || needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + runs-on: ubuntu-latest + concurrency: + group: preview-documentation-${{ needs.resolve-context.outputs.source-branch || needs.resolve-context.outputs.pr-number || github.run_id }} + cancel-in-progress: true + steps: + - name: Checkout default branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve-context.outputs.default-branch }} + + - name: Setup shared workspace + uses: ./.github/actions/devflare-setup-workspace + + - name: Clean up documentation branch preview scope + if: ${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' }} + shell: bash + env: + PREVIEW_SCOPE: ${{ needs.resolve-context.outputs.branch-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + cd apps/documentation + + bunx --bun devflare previews cleanup \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --scope "$PREVIEW_SCOPE" \ + --apply + + - name: Mark documentation branch preview deployment inactive + if: ${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: cleanup + status: inactive + title: Documentation branch preview + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.branch-preview-scope }} + environment: documentation branch preview / ${{ needs.resolve-context.outputs.branch-preview-scope }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + summary: This branch was deleted, so the shared preview workflow cleaned up the named documentation preview scope and marked the matching GitHub deployment inactive. + + - name: Clean up documentation PR preview scope + if: ${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + shell: bash + env: + PREVIEW_SCOPE: ${{ needs.resolve-context.outputs.pr-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + cd apps/documentation + + bunx --bun devflare previews cleanup \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --scope "$PREVIEW_SCOPE" \ + --apply + + - name: Publish documentation PR preview cleanup feedback + if: ${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: comment + operation: cleanup + status: inactive + title: Documentation PR preview + comment-key: pr-deployment-status + comment-section-key: documentation-preview + comment-group-title: Pull request deployment status + comment-group-summary: This single comment tracks the latest documentation and testing preview results for this pull request and is updated in place by the shared preview workflow. + pr-number: ${{ needs.resolve-context.outputs.pr-number }} + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.source-branch }} + summary: This pull request was closed, so the shared preview workflow deleted the PR-scoped documentation preview Worker, cleaned up preview-owned resources, and marked the shared PR deployment comment section inactive. + + - name: Summarize documentation cleanup + if: ${{ always() }} + shell: bash + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ### Documentation cleanup + + - Branch cleanup: `${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' && needs.resolve-context.outputs.branch-preview-scope || 'not requested' }}` + - PR cleanup: `${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' && needs.resolve-context.outputs.pr-preview-scope || 'not requested' }}` + EOF + + testing-preview: + name: Testing preview + needs: resolve-context + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' || needs.resolve-context.outputs.pr-preview-enabled == 'true' }} + runs-on: ubuntu-latest + concurrency: + group: preview-testing-${{ needs.resolve-context.outputs.source-branch || needs.resolve-context.outputs.pr-number || github.run_id }} + cancel-in-progress: true + steps: + - name: Checkout preview source + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.resolve-context.outputs.checkout-ref }} + + - name: Setup shared workspace + uses: ./.github/actions/devflare-setup-workspace + + - name: Resolve testing auth service deploy impact + id: auth-impact + uses: ./.github/actions/devflare-deploy-impact + with: + target-package: testing-auth-service + default-branch: ${{ needs.resolve-context.outputs.default-branch }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action || '' }} + push-before: ${{ github.event.before || '' }} + pull-request-base-sha: ${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: ${{ github.event.pull_request.head.sha || '' }} + extra-paths: | + apps/testing/worker-names.ts + + - name: Resolve testing search service deploy impact + id: search-impact + uses: ./.github/actions/devflare-deploy-impact + with: + target-package: testing-search-service + default-branch: ${{ needs.resolve-context.outputs.default-branch }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action || '' }} + push-before: ${{ github.event.before || '' }} + pull-request-base-sha: ${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: ${{ github.event.pull_request.head.sha || '' }} + extra-paths: | + apps/testing/worker-names.ts + + - name: Resolve testing main preview deploy impact + id: main-impact + uses: ./.github/actions/devflare-deploy-impact + with: + target-package: testing + default-branch: ${{ needs.resolve-context.outputs.default-branch }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action || '' }} + push-before: ${{ github.event.before || '' }} + pull-request-base-sha: ${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: ${{ github.event.pull_request.head.sha || '' }} + + - name: Deploy testing auth service for branch preview + id: branch-auth + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.auth-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing/workers/auth-service + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.branch-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy testing search service for branch preview + id: branch-search + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.search-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing/workers/search-service + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.branch-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy testing branch preview + id: branch-deploy + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.main-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.branch-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify testing branch preview deployed bindings + id: branch-verify + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && steps.main-impact.outputs.should-deploy == 'true' && always() }} + continue-on-error: true + shell: bash + env: + DEVFLARE_PREVIEW_BRANCH: ${{ needs.resolve-context.outputs.branch-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ACCESS_CLIENT_ID: ${{ secrets.CLOUDFLARE_ACCESS_CLIENT_ID }} + CLOUDFLARE_ACCESS_CLIENT_SECRET: ${{ secrets.CLOUDFLARE_ACCESS_CLIENT_SECRET }} + TESTING_DEPLOY_VERSION_ID: ${{ steps.branch-deploy.outputs.version-id }} + # The branch-preview workers are commonly gated by a Cloudflare Access + # policy. Live /health and /status probes only work when the Access + # service-token secrets are configured. Without them the verifier + # would (correctly) fail on every Access-redirect; expose the URL only + # when we can authenticate, otherwise emit a loud warning so the gap + # is visible. PR-preview workers are not gated, so this only affects + # the branch lane. + TESTING_DEPLOY_PREVIEW_URL: ${{ (secrets.CLOUDFLARE_ACCESS_CLIENT_ID != '' && secrets.CLOUDFLARE_ACCESS_CLIENT_SECRET != '') && steps.branch-deploy.outputs.preview-url || '' }} + TESTING_VERIFICATION_ATTEMPTS: '5' + TESTING_VERIFICATION_DELAY_MS: '3000' + run: | + set -euo pipefail + + if [ -z "${{ steps.branch-deploy.outputs.preview-url }}" ]; then + echo 'Expected the deploy action to return a preview-url output for GitHub feedback.' >&2 + exit 1 + fi + + if [ -z "$TESTING_DEPLOY_PREVIEW_URL" ]; then + warning='Branch-preview live /health + /status probe SKIPPED: CLOUDFLARE_ACCESS_CLIENT_ID / CLOUDFLARE_ACCESS_CLIENT_SECRET secrets are not configured, and the branch-preview workers are gated by Cloudflare Access. Add the service-token secrets to enable end-to-end deploy verification on push events. Falling back to Wrangler metadata only.' + echo "::warning::$warning" + { + echo '### :warning: Branch-preview live verification skipped' + echo '' + echo "$warning" + } >> "$GITHUB_STEP_SUMMARY" + fi + + bun .github/scripts/verify-testing-preview-deployment.ts "$DEVFLARE_PREVIEW_BRANCH" + + - name: Publish testing branch preview feedback + if: ${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && (steps.auth-impact.outputs.should-deploy == 'true' || steps.search-impact.outputs.should-deploy == 'true' || steps.main-impact.outputs.should-deploy == 'true') && always() }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: report + status: ${{ (steps.auth-impact.outputs.should-deploy != 'true' || steps.branch-auth.outputs.status == 'success') && (steps.search-impact.outputs.should-deploy != 'true' || steps.branch-search.outputs.status == 'success') && (steps.main-impact.outputs.should-deploy != 'true' || (steps.branch-deploy.outputs.status == 'success' && steps.branch-verify.outcome == 'success')) && 'success' || 'failure' }} + title: Testing branch preview + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.branch-preview-scope }} + sha: ${{ github.sha }} + environment: testing branch preview / ${{ needs.resolve-context.outputs.branch-preview-scope }} + environment-url: ${{ steps.branch-deploy.outputs.preview-url }} + preview-url: ${{ steps.branch-deploy.outputs.preview-url }} + version-id: ${{ steps.branch-deploy.outputs.version-id }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + log-excerpt: ${{ steps.branch-deploy.outputs.log-excerpt }} + summary: This shared preview workflow keeps the named testing preview scope updated on qualifying pushes. + details-markdown: | + - Auth impact: `${{ steps.auth-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.auth-impact.outputs.reason }}`) + - Search impact: `${{ steps.search-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.search-impact.outputs.reason }}`) + - Main preview impact: `${{ steps.main-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.main-impact.outputs.reason }}`) + - Deployed binding verification: `${{ steps.main-impact.outputs.should-deploy != 'true' && 'skipped' || (steps.branch-verify.outcome == 'success' && 'passed' || 'failed') }}` + - Preview strategy: branch pushes build the workspace once per job, then update both branch and PR targets when the branch already belongs to an open pull request. + + - name: Deploy testing auth service for PR preview + id: pr-auth + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.auth-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing/workers/auth-service + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy testing search service for PR preview + id: pr-search + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.search-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing/workers/search-service + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Deploy testing PR preview + id: pr-deploy + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.main-impact.outputs.should-deploy == 'true' }} + continue-on-error: true + uses: ./.github/actions/devflare-deploy + with: + working-directory: apps/testing + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + preview-scope: ${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify testing PR preview deployed bindings + id: pr-verify + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && steps.main-impact.outputs.should-deploy == 'true' && always() }} + continue-on-error: true + shell: bash + env: + DEVFLARE_PREVIEW_BRANCH: ${{ needs.resolve-context.outputs.pr-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_ACCESS_CLIENT_ID: ${{ secrets.CLOUDFLARE_ACCESS_CLIENT_ID }} + CLOUDFLARE_ACCESS_CLIENT_SECRET: ${{ secrets.CLOUDFLARE_ACCESS_CLIENT_SECRET }} + TESTING_DEPLOY_VERSION_ID: ${{ steps.pr-deploy.outputs.version-id }} + TESTING_DEPLOY_PREVIEW_URL: ${{ steps.pr-deploy.outputs.preview-url }} + TESTING_VERIFICATION_ATTEMPTS: '5' + TESTING_VERIFICATION_DELAY_MS: '3000' + run: | + set -euo pipefail + + if [ -z "${{ steps.pr-deploy.outputs.preview-url }}" ]; then + echo 'Expected the deploy action to return a preview-url output for GitHub feedback.' >&2 + exit 1 + fi + + bun .github/scripts/verify-testing-preview-deployment.ts "$DEVFLARE_PREVIEW_BRANCH" + + - name: Publish testing PR preview feedback + if: ${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && always() }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: comment + operation: report + status: ${{ (steps.auth-impact.outputs.should-deploy != 'true' && steps.search-impact.outputs.should-deploy != 'true' && steps.main-impact.outputs.should-deploy != 'true') && 'skipped' || (((steps.auth-impact.outputs.should-deploy != 'true' || steps.pr-auth.outputs.status == 'success') && (steps.search-impact.outputs.should-deploy != 'true' || steps.pr-search.outputs.status == 'success') && (steps.main-impact.outputs.should-deploy != 'true' || (steps.pr-deploy.outputs.status == 'success' && steps.pr-verify.outcome == 'success'))) && 'success' || 'failure') }} + title: Testing PR preview + comment-key: pr-deployment-status + comment-section-key: testing-preview + comment-group-title: Pull request deployment status + comment-group-summary: This single comment tracks the latest documentation and testing preview results for this pull request and is updated in place by the shared preview workflow. + pr-number: ${{ needs.resolve-context.outputs.pr-number }} + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.source-branch }} + sha: ${{ needs.resolve-context.outputs.checkout-ref }} + preview-url: ${{ steps.pr-deploy.outputs.preview-url }} + version-id: ${{ steps.pr-deploy.outputs.version-id }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + log-excerpt: ${{ steps.pr-deploy.outputs.log-excerpt }} + summary: ${{ (steps.auth-impact.outputs.should-deploy != 'true' && steps.search-impact.outputs.should-deploy != 'true' && steps.main-impact.outputs.should-deploy != 'true') && 'No testing preview redeploy was needed for this run, so the existing PR-scoped testing preview remains in place.' || 'This pull request gets a stable PR-scoped testing preview family that the shared preview workflow updates in place on every relevant branch push or PR lifecycle event.' }} + details-markdown: | + - Auth impact: `${{ steps.auth-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.auth-impact.outputs.reason }}`) + - Search impact: `${{ steps.search-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.search-impact.outputs.reason }}`) + - Main preview impact: `${{ steps.main-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.main-impact.outputs.reason }}`) + - Deployed binding verification: `${{ steps.main-impact.outputs.should-deploy != 'true' && 'skipped' || (steps.pr-verify.outcome == 'success' && 'passed' || 'failed') }}` + - Preview strategy: branch pushes build the workspace once per job, then update both branch and PR targets when the branch already belongs to an open pull request. + - PR scope: `#${{ needs.resolve-context.outputs.pr-number }}` + + - name: Summarize testing preview + if: ${{ always() }} + shell: bash + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ### Testing preview + + - Auth impact: `${{ steps.auth-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.auth-impact.outputs.reason }}`) + - Search impact: `${{ steps.search-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.search-impact.outputs.reason }}`) + - Main preview impact: `${{ steps.main-impact.outputs.should-deploy == 'true' && 'deploy' || 'skip' }}` (`${{ steps.main-impact.outputs.reason }}`) + - Branch target: `${{ needs.resolve-context.outputs.branch-preview-enabled == 'true' && needs.resolve-context.outputs.branch-preview-scope || 'disabled' }}` + - PR target: `${{ needs.resolve-context.outputs.pr-preview-enabled == 'true' && needs.resolve-context.outputs.pr-preview-scope || 'disabled' }}` + EOF + + if [ "${{ needs.resolve-context.outputs.branch-preview-enabled }}" = 'true' ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - Branch preview status: `${{ (steps.auth-impact.outputs.should-deploy != 'true' || steps.branch-auth.outputs.status == 'success') && (steps.search-impact.outputs.should-deploy != 'true' || steps.branch-search.outputs.status == 'success') && (steps.main-impact.outputs.should-deploy != 'true' || (steps.branch-deploy.outputs.status == 'success' && steps.branch-verify.outcome == 'success')) && 'success' || 'failure' }}` + EOF + fi + + if [ "${{ needs.resolve-context.outputs.pr-preview-enabled }}" = 'true' ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + - PR preview status: `${{ (steps.auth-impact.outputs.should-deploy != 'true' && steps.search-impact.outputs.should-deploy != 'true' && steps.main-impact.outputs.should-deploy != 'true') && 'skipped' || (((steps.auth-impact.outputs.should-deploy != 'true' || steps.pr-auth.outputs.status == 'success') && (steps.search-impact.outputs.should-deploy != 'true' || steps.pr-search.outputs.status == 'success') && (steps.main-impact.outputs.should-deploy != 'true' || (steps.pr-deploy.outputs.status == 'success' && steps.pr-verify.outcome == 'success'))) && 'success' || 'failure') }}` + EOF + fi + + - name: Fail when any testing preview deploy or verification step did not succeed + if: ${{ always() }} + shell: bash + env: + BRANCH_ENABLED: ${{ needs.resolve-context.outputs.branch-preview-enabled }} + BRANCH_AUTH_IMPACT: ${{ steps.auth-impact.outputs.should-deploy }} + BRANCH_AUTH_STATUS: ${{ steps.branch-auth.outputs.status }} + BRANCH_SEARCH_IMPACT: ${{ steps.search-impact.outputs.should-deploy }} + BRANCH_SEARCH_STATUS: ${{ steps.branch-search.outputs.status }} + BRANCH_MAIN_IMPACT: ${{ steps.main-impact.outputs.should-deploy }} + BRANCH_MAIN_STATUS: ${{ steps.branch-deploy.outputs.status }} + BRANCH_VERIFY_OUTCOME: ${{ steps.branch-verify.outcome }} + PR_ENABLED: ${{ needs.resolve-context.outputs.pr-preview-enabled }} + PR_AUTH_IMPACT: ${{ steps.auth-impact.outputs.should-deploy }} + PR_AUTH_STATUS: ${{ steps.pr-auth.outputs.status }} + PR_SEARCH_IMPACT: ${{ steps.search-impact.outputs.should-deploy }} + PR_SEARCH_STATUS: ${{ steps.pr-search.outputs.status }} + PR_MAIN_IMPACT: ${{ steps.main-impact.outputs.should-deploy }} + PR_MAIN_STATUS: ${{ steps.pr-deploy.outputs.status }} + PR_VERIFY_OUTCOME: ${{ steps.pr-verify.outcome }} + run: | + set -euo pipefail + + should_fail='false' + + if [ "$BRANCH_ENABLED" = 'true' ]; then + if [ "$BRANCH_AUTH_IMPACT" = 'true' ] && [ "$BRANCH_AUTH_STATUS" != 'success' ]; then + should_fail='true' + fi + + if [ "$BRANCH_SEARCH_IMPACT" = 'true' ] && [ "$BRANCH_SEARCH_STATUS" != 'success' ]; then + should_fail='true' + fi + + if [ "$BRANCH_MAIN_IMPACT" = 'true' ] && { [ "$BRANCH_MAIN_STATUS" != 'success' ] || [ "$BRANCH_VERIFY_OUTCOME" = 'failure' ]; }; then + should_fail='true' + fi + fi + + if [ "$PR_ENABLED" = 'true' ]; then + if [ "$PR_AUTH_IMPACT" = 'true' ] && [ "$PR_AUTH_STATUS" != 'success' ]; then + should_fail='true' + fi + + if [ "$PR_SEARCH_IMPACT" = 'true' ] && [ "$PR_SEARCH_STATUS" != 'success' ]; then + should_fail='true' + fi + + if [ "$PR_MAIN_IMPACT" = 'true' ] && { [ "$PR_MAIN_STATUS" != 'success' ] || [ "$PR_VERIFY_OUTCOME" = 'failure' ]; }; then + should_fail='true' + fi + fi + + if [ "$should_fail" != 'true' ]; then + exit 0 + fi + + echo 'Testing preview deployment or deployed-binding verification failed.' >&2 + exit 1 + + testing-cleanup: + name: Testing cleanup + needs: resolve-context + if: ${{ (needs.resolve-context.outputs.branch-cleanup-enabled == 'true' && (needs.resolve-context.outputs.cleanup-project == 'all' || needs.resolve-context.outputs.cleanup-project == 'testing')) || needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + runs-on: ubuntu-latest + concurrency: + group: preview-testing-${{ needs.resolve-context.outputs.source-branch || needs.resolve-context.outputs.pr-number || github.run_id }} + cancel-in-progress: true + steps: + - name: Checkout default branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve-context.outputs.default-branch }} + + - name: Setup shared workspace + uses: ./.github/actions/devflare-setup-workspace + + - name: Clean up testing branch preview scope + if: ${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' }} + shell: bash + env: + PREVIEW_SCOPE: ${{ needs.resolve-context.outputs.branch-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + cd apps/testing + + bunx --bun devflare previews cleanup \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --scope "$PREVIEW_SCOPE" \ + --apply + + - name: Mark testing branch preview deployment inactive + if: ${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: deployment + operation: cleanup + status: inactive + title: Testing branch preview + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.branch-preview-scope }} + environment: testing branch preview / ${{ needs.resolve-context.outputs.branch-preview-scope }} + log-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + summary: This branch was deleted, so the shared preview workflow cleaned up the named testing preview scope and marked the matching GitHub deployment inactive. + + - name: Clean up testing PR preview scope + if: ${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + shell: bash + env: + PREVIEW_SCOPE: ${{ needs.resolve-context.outputs.pr-preview-scope }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + cd apps/testing + + bunx --bun devflare previews cleanup \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --scope "$PREVIEW_SCOPE" \ + --apply + + - name: Publish testing PR preview cleanup feedback + if: ${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' }} + uses: ./.github/actions/devflare-github-feedback + with: + github-token: ${{ github.token }} + mode: comment + operation: cleanup + status: inactive + title: Testing PR preview + comment-key: pr-deployment-status + comment-section-key: testing-preview + comment-group-title: Pull request deployment status + comment-group-summary: This single comment tracks the latest documentation and testing preview results for this pull request and is updated in place by the shared preview workflow. + pr-number: ${{ needs.resolve-context.outputs.pr-number }} + deployment-kind: preview + ref-name: ${{ needs.resolve-context.outputs.source-branch }} + summary: This pull request was closed, so the shared preview workflow cleaned up the PR-scoped testing preview Workers plus preview-owned Cloudflare resources and marked the shared PR deployment comment section inactive. + + - name: Summarize testing cleanup + if: ${{ always() }} + shell: bash + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ### Testing cleanup + + - Branch cleanup: `${{ needs.resolve-context.outputs.branch-cleanup-enabled == 'true' && needs.resolve-context.outputs.branch-preview-scope || 'not requested' }}` + - PR cleanup: `${{ needs.resolve-context.outputs.pr-cleanup-enabled == 'true' && needs.resolve-context.outputs.pr-preview-scope || 'not requested' }}` + EOF diff --git a/.github/workflows/workspace-ci.yml b/.github/workflows/workspace-ci.yml new file mode 100644 index 0000000..d7d8fc2 --- /dev/null +++ b/.github/workflows/workspace-ci.yml @@ -0,0 +1,74 @@ +name: Workspace CI + +on: + pull_request: + paths: + - "apps/documentation/**" + - "cases/**" + - "packages/**" + - "package.json" + - "bun.lock" + - "turbo.json" + - "biome.json" + - "tsconfig.json" + - ".github/workflows/workspace-ci.yml" + push: + branches: + - main + paths: + - "apps/documentation/**" + - "cases/**" + - "packages/**" + - "package.json" + - "bun.lock" + - "turbo.json" + - "biome.json" + - "tsconfig.json" + - ".github/workflows/workspace-ci.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + env: + TURBO_TELEMETRY_DISABLED: "1" + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + + - name: Restore Bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-1.3.12- + + - name: Restore Turborepo cache + uses: actions/cache@v4 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ hashFiles('bun.lock', 'package.json', 'turbo.json', 'biome.json', 'tsconfig.json', 'cases/tsconfig.base.json', 'apps/*/package.json', 'apps/testing/workers/*/package.json', 'packages/*/package.json', 'cases/**/package.json') }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Install workspace dependencies + shell: bash + run: bun install --frozen-lockfile + + - name: Run cached devflare validation lane + shell: bash + run: bun run devflare:ci \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb7b05e --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Dependencies +**/node_modules/ +.pnp +.pnp.js +.vite +memories +.local +.jscpd-* +FINDINGS.md +INCONSISTENCIES.md +REMAINING.md +results.csv + +packages/devflare/_*.txt +_*.txt +**/build_output*.txt +output.txt + +# Build outputs +dist/ +*.tsbuildinfo + +# Generated files +wrangler.jsonc +.wrangler/ +.mf/ +.devflare/ +.turbo/ +.svelte-kit/ + +# Environment files +.env +.env.* +!.env.example +.secrets +.secrets.* +!.secrets.example + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Test coverage +coverage/ +.nyc_output/ + +# Misc +*.local diff --git a/README.md b/README.md index 3b4c009..812bd89 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ -# Devflare +# Devflare Monorepo -Something great is being built hereโœจ \ No newline at end of file +This repository contains the core `devflare` package, the documentation app, and the example cases that exercise the framework from a few different angles. + +## Workspace layout + +- `packages/devflare` โ€” the main package and CLI +- `apps/documentation` โ€” the docs app and the primary SvelteKit/Vite consumer in CI +- `cases/*` โ€” focused examples and regression cases for specific features + +## Turborepo workflow + +The clean contributor workflow for the core `devflare` package now lives behind explicit Turbo-backed scripts at the repo root: + +- `bun run devflare:dev` โ€” run the package in watch mode +- `bun run devflare:test:watch` โ€” watch the package test suite +- `bun run devflare:build` โ€” build `devflare` and the documentation app +- `bun run devflare:typecheck` โ€” typecheck the `devflare` package itself +- `bun run devflare:test` โ€” run the stable downstream test lane for `devflare` dependents +- `bun run devflare:types` โ€” regenerate dependent package types through Turbo +- `bun run devflare:check` โ€” run the documentation app check lane +- `bun run devflare:ci` โ€” run the full validated `devflare` contributor lane + +Common aliases and broader monorepo lanes: + +- `bun run dev` โ€” alias for `devflare:dev` +- `bun run test` / `bun run test:watch` โ€” alias for the `devflare:test*` lanes +- `bun run typecheck` / `bun run types` / `bun run check` โ€” workspace-wide typecheck through Turbo +- `bun run typecheck:root` โ€” typecheck only the repo-root TypeScript surface (no workspace recursion) +- `bun run build` โ€” workspace-wide build through Turbo +- `bun run lint` / `bun run lint:fix` / `bun run lint:root` โ€” Biome-based linting (workspace and root) +- `bun run ci` โ€” alias for `devflare:ci` +- `bun run ci:strict` โ€” root lint + root typecheck + `devflare:ci` (strict gate used in CI) + +## Notes + +- The shared Turbo lane intentionally excludes `@devflare/case5-multi-worker` from the default test pass because that case is not currently stable in the shared contributor workflow. +- The shared `check` lane stays focused on `apps/documentation`; `cases/case18` still expects Cloudflare-backed resource resolution that is outside the default local/CI lane. +- Package-level usage and API docs live in `packages/devflare/README.md`. \ No newline at end of file diff --git a/apps/documentation/.gitignore b/apps/documentation/.gitignore new file mode 100644 index 0000000..54c75f3 --- /dev/null +++ b/apps/documentation/.gitignore @@ -0,0 +1,36 @@ +node_modules +.devflare +.adapter-cloudflare +env.d.ts + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +# Generated Types +/worker-configuration.d.ts +# Paraglide +src/lib/paraglide +project.inlang/cache/ + +# Generated documentation exports +static/LLM.md +static/LLM.txt +static/social-cards/ diff --git a/apps/documentation/.npmrc b/apps/documentation/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/apps/documentation/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/documentation/README.md b/apps/documentation/README.md new file mode 100644 index 0000000..a4b416a --- /dev/null +++ b/apps/documentation/README.md @@ -0,0 +1,72 @@ +# Documentation app + +This app is the repo's real-world Devflare-backed SvelteKit example. + +It intentionally demonstrates that: + +- `devflare.config.ts` is the authored source of truth +- Wrangler config is generated under `.devflare/` and `.wrangler/deploy/` +- SvelteKit can compose `devflare/sveltekit` with existing hooks +- `devflare dev`, `devflare build`, `devflare deploy`, and `devflare deploy --preview` are the primary flows +- `.github/workflows/preview.yml` handles documentation and testing preview deploys, branch/PR feedback, and cleanup flows from one shared workflow +- branch pushes can prepare the workspace once and then refresh both the branch preview target and the matching PR preview target when the branch already belongs to an open pull request +- `.github/workflows/documentation-production.yml` is the production-on-default-branch workflow that publishes a GitHub deployment status with the production URL + +## Scripts + +```sh +bun run types +bun run dev +bun run build +bun run deploy +bun run deploy:preview +bun run check +``` + +## Documentation contribution contract + +- Author long-form docs in `apps/documentation/src/lib/docs/content*.ts`; do not patch generated `LLM.md` by hand. +- Start feature pages with a copyable recipe: file path, command, expected result, and the next page to read. +- Prefer many small examples over broad prose. Multi-file examples should name every file in the snippet metadata. +- When public exports, config schema keys, CLI commands, binding support, or test helpers change, update the matching docs and run `bun run devflare:docs-integrity` from the repo root. +- Regenerate the handbook with `bun run --cwd packages/devflare llm:generate` when the docs model changes. + +## Monorepo + Turborepo workflow + +This app lives inside the repository's Bun + Turborepo workspace, so there are two layers to keep straight: + +- the repo root uses Turbo to orchestrate validation, caching, and impacted-package work +- this package still owns the actual `devflare` config and deployment commands through `apps/documentation/devflare.config.ts` + +That means Turbo is the right tool for workspace-wide validation such as: + +- `bun run devflare:build` +- `bun run devflare:check` +- `bun run devflare:ci` +- `bun run turbo build --filter=documentation` +- `bun run turbo check --filter=documentation` + +But the actual deploy should still run from the package that owns the app: + +```sh +# from the repo root +bun run turbo build --filter=documentation +bun run turbo check --filter=documentation + +# from apps/documentation +bun run deploy -- --preview feature-search +bun run deploy -- --prod +``` + +In GitHub Actions, keep the same split: + +- use Turbo or path-aware workflow conditions to decide whether the docs app needs work +- run the deploy step with `working-directory: apps/documentation` (or equivalent) so `devflare` resolves this package's local config on purpose + +## Notes + +- Do not add a hand-maintained `wrangler.jsonc` next to `devflare.config.ts` +- Devflare generates Wrangler config for this app under `.devflare/` and writes Wrangler's deploy redirect under `.wrangler/deploy/config.json` +- `preview_urls: true` and `workers_dev: true` are enabled so preview uploads can surface usable preview links for this app +- The app keeps Paraglide middleware and composes the Devflare SvelteKit handle ahead of it +- If you want branch-only or combined branch + PR GitHub feedback, use `.github/actions/devflare-github-feedback` with `mode: deployment` or `mode: both` in a branch-scoped workflow rather than trying to invent a branch-comment surface that GitHub does not actually have diff --git a/apps/documentation/bun.lock b/apps/documentation/bun.lock new file mode 100644 index 0000000..50773ce --- /dev/null +++ b/apps/documentation/bun.lock @@ -0,0 +1,952 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "documentation", + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@inlang/paraglide-js": "^2.15.2", + "@sveltejs/adapter-cloudflare": "^7.2.8", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "devflare": "file:../../packages/devflare", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7", + "wrangler": "^4.81.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + + "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.31.1", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.0", "miniflare": "4.20260405.0", "unenv": "2.0.0-rc.24", "wrangler": "4.81.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" } }, "sha512-vw4pOS8FmODdCeWjAG0gO4OyZ4Bb4GXlET/taaLDRm7gC5uGcH5XRPoTUJPYrs54LbWZxi3e2iWXX3JLRv4Rfg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260405.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EbmdBcmeIGogKG4V1odSWQe7z4rHssUD4iaXv0cXA22/MFrzH3iQT0R+FJFyhucGtih/9B9E+6j0QbSQD8xT3w=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260405.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-r44r418bOQtoP+Odu+L/BQM9q5cRSXRd1N167PgZQIo4MlqzTwHO4L0wwXhxbcV/PF46rrQre/uTFS8R0R+xSQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260405.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Aaq3RWnaTCzMBo77wC8fjOx+SFdO/rlcXa6HAf+PJs51LyMISFOBCJKqSlS6Irphen0WHHxFKPHUO9bjfj8g2g=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260405.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lbp9Z2wiMzy3Sji3YwMHK5WDlejsH3jF4swAFEv7+jIf3NowZHga3GzwTypNRmcwnfz/XrqQ7Hc0Ul9OoU/lCw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260405.1", "", { "os": "win32", "cpu": "x64" }, "sha512-FhE0kt93kj5JnSPVqi4BAXpQQENyKnuSOoJLd35mkMMGhtPrwv5EsReJdck0S8hUocCBlb+U0RmP8ta6k41HjQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260408.1", "", {}, "sha512-kE1tKfHUyIldsj3ea2XEqvLRHkDwc83YM7nar6SS5+cj81IoAFR/OZNDwZWHb6vx+pC31PBJGtROlfZzsgxudQ=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@inlang/paraglide-js": ["@inlang/paraglide-js@2.15.2", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.9.1", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "bin": { "paraglide-js": "bin/run.js" } }, "sha512-1S2jBvc8jzJAZFRf3gKu3Z2+9zQRhvIzALEE4vvWDNIoiiOn0vF3cJHf3xFqgfN/JY5IVS//zQsvAT0jWXH69g=="], + + "@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="], + + "@inlang/sdk": ["@inlang/sdk@2.9.1", "", { "dependencies": { "@lix-js/sdk": "0.4.9", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^13.0.0" } }, "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@lix-js/sdk": ["@lix-js/sdk@0.4.9", "", { "dependencies": { "@lix-js/server-protocol-schema": "0.1.1", "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A=="], + + "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-project/types": ["@oxc-project/types@0.123.0", "", {}, "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.13", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm" }, "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.13", "", { "os": "linux", "cpu": "x64" }, "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.13", "", { "os": "none", "cpu": "arm64" }, "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.13", "", { "dependencies": { "@emnapi/core": "1.9.1", "@emnapi/runtime": "1.9.1", "@napi-rs/wasm-runtime": "^1.1.2" }, "cpu": "none" }, "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.13", "", { "os": "win32", "cpu": "x64" }, "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.13", "", {}, "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.31.28", "", {}, "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-cloudflare": ["@sveltejs/adapter-cloudflare@7.2.8", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250507.0", "worktop": "0.8.0-next.18" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0", "wrangler": "^4.0.0" } }, "sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.57.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-TMiqCTy9ZW4KBHvmTgeWU/hF6jcFpeMgR+9ekE06uhhGnbUZ7wpIY6l1Uk4ThRzlWYJnCVfzmtVNaHaDjaSiSg=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.6.0", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA=="], + + "bare-os": ["bare-os@3.8.7", "", {}, "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.12.0", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g=="], + + "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], + + "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "c12": ["c12@2.0.4", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.1.8", "defu": "^6.1.4", "dotenv": "^16.4.7", "giget": "^1.2.4", "jiti": "^2.4.2", "mlly": "^1.7.4", "ohash": "^2.0.4", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^1.3.1", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-3DbbhnFt0fKJHxU4tEUPmD1ahWE4PWPMomqfYsTJdrhpmEnRKJi3qSC4rO5U6E6zN1+pjBY7+z8fUmNRMaVKLw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.7.0", "", {}, "sha512-qCvc8m7cImp1QDCsiY+C2EdSBWSj7Ucfoq87scSdYboDiIKdvMtFbH1U2VReBls6WMhMaUOoK3ZJEDNG/7zm3w=="], + + "devflare": ["devflare@file:../../packages/devflare", { "dependencies": { "@puppeteer/browsers": "^2.10.3", "c12": "^2.0.1", "chokidar": "^4.0.3", "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "es-module-lexer": "^1.6.0", "execa": "^9.5.2", "fast-glob": "^3.3.3", "globby": "^16.1.0", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.17", "pathe": "^2.0.2", "picomatch": "^4.0.3", "puppeteer-core": "^24.5.0", "rolldown": "^1.0.0-rc.12", "ws": "^8.19.0", "zod": "^3.24.1" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.0.0", "@cloudflare/workers-types": "^4.20250109.0", "@types/bun": "^1.1.14", "@types/picomatch": "^4.0.2", "@types/ws": "^8.18.1", "miniflare": "^3.20250109.0", "typescript": "^5.7.2", "vite": "^6.0.0" }, "peerDependencies": { "@cloudflare/vite-plugin": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "wrangler": "^3.99.0" }, "optionalPeers": ["@cloudflare/vite-plugin", "vite"], "bin": { "devflare": "./bin/devflare.js" } }], + + "devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-sha256": ["js-sha256@0.11.1", "", {}, "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "kysely": ["kysely@0.28.15", "", {}, "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "miniflare": ["miniflare@4.20260405.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260405.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-tpr4XdWMq7zFdsHH+CS0XS47nQzlRZH0rMJ1vobOZbkrs3cIj7qbD40ON616hDnzHxwqwB2qKHzmmuj6oRisSQ=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "netmask": ["netmask@2.1.0", "", {}, "sha512-z9sZrk6wyf8/NDKKqe+Tyl58XtgkYrV4kgt1O8xrzYvpl1LvPacPo0imMLHfpStk3kgCIq1ksJ2bmJn9hue2lQ=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "puppeteer-core": ["puppeteer-core@24.40.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1581282", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rolldown": ["rolldown@1.0.0-rc.13", "", { "dependencies": { "@oxc-project/types": "=0.123.0", "@rolldown/pluginutils": "1.0.0-rc.13" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", "@rolldown/binding-darwin-x64": "1.0.0-rc.13", "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="], + + "stacktracey": ["stacktracey@2.2.0", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "svelte": ["svelte@5.55.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw=="], + + "svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + + "vite": ["vite@8.0.7", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.13", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "workerd": ["workerd@1.20260405.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260405.1", "@cloudflare/workerd-darwin-arm64": "1.20260405.1", "@cloudflare/workerd-linux-64": "1.20260405.1", "@cloudflare/workerd-linux-arm64": "1.20260405.1", "@cloudflare/workerd-windows-64": "1.20260405.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-bSaRWCv9iO8/FWpgZRjHLGZLolX5s1AErRSYaTECMMHOZKuCbl2+ehnSyc+ZZ/70y+9owADmN6HoYEWvBlJdYw=="], + + "worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="], + + "wrangler": ["wrangler@4.81.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260405.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260405.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260405.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-9fLPDuDcb8Nu6iXrl5E3HGYt3TVhQr/UvqtTvWr9Nl1X7PlQrmWMwQCfSioqN8VHYyQCyESV5jQsoKg8Sx+sEA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@lix-js/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "devflare/miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], + + "devflare/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "devflare/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "devflare/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "npm-run-path/unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "puppeteer-core/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "devflare/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "devflare/miniflare/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "devflare/miniflare/workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], + + "devflare/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "devflare/miniflare/youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "devflare/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "devflare/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "devflare/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="], + + "devflare/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="], + + "devflare/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="], + + "devflare/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="], + + "devflare/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], + + "devflare/miniflare/youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "devflare/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "devflare/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "devflare/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "devflare/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "devflare/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "devflare/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "devflare/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "devflare/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "devflare/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "devflare/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "devflare/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "devflare/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "devflare/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "devflare/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "devflare/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "devflare/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "devflare/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "devflare/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "devflare/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "devflare/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "devflare/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "devflare/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "devflare/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "devflare/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "devflare/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "devflare/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + } +} diff --git a/apps/documentation/devflare.config.ts b/apps/documentation/devflare.config.ts new file mode 100644 index 0000000..049f94d --- /dev/null +++ b/apps/documentation/devflare.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '../../packages/devflare/src/config-entry' +import { resolveDocumentationWorkerName } from './worker-name' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: resolveDocumentationWorkerName(), + compatibilityDate: '2026-04-08', + files: { + fetch: false + }, + previews: { + includeCrons: false + }, + accountId, + assets: { + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }, + vars: { + BUILD_SHA: process.env.GITHUB_SHA ?? 'local-dev', + BUILD_TIME: new Date().toISOString() + }, + wrangler: { + passthrough: { + main: '.adapter-cloudflare/_worker.js' + } + } +}) \ No newline at end of file diff --git a/apps/documentation/inspect_docs.ps1 b/apps/documentation/inspect_docs.ps1 new file mode 100644 index 0000000..049e847 --- /dev/null +++ b/apps/documentation/inspect_docs.ps1 @@ -0,0 +1,26 @@ +$files = Get-ChildItem -Path src\lib\docs\content\*.ts +$results = @() +foreach ($file in $files) { + $content = Get-Content -Raw -Path $file.FullName + # Find all navTitle matches + $matches = [regex]::Matches($content, 'navTitle:\s*[''"](.+?)[''"]') + for ($i = 0; $i -lt $matches.Count; $i++) { + $title = $matches[$i].Groups[1].Value + $startIndex = $matches[$i].Index + $endIndex = if ($i + 1 -lt $matches.Count) { $matches[$i+1].Index } else { $content.Length } + $pageContent = $content.Substring($startIndex, $endIndex - $startIndex) + $hasSnippets = $pageContent -match "snippets:" + $results += [PSCustomObject]@{ + Title = $title + HasSnippets = $hasSnippets + File = $file.Name + } + } +} +$results | Format-Table -AutoSize +$totalPages = $results.Count +$withSnippets = ($results | Where-Object { $_.HasSnippets }).Title +$withoutSnippets = ($results | Where-Object { !$_.HasSnippets }).Title +Write-Host "Total Pages: $totalPages" +Write-Host "With Snippets: $($withSnippets.Count)" +Write-Host "Without Snippets: $($withoutSnippets.Count)" diff --git a/apps/documentation/messages/en.json b/apps/documentation/messages/en.json new file mode 100644 index 0000000..eebbf69 --- /dev/null +++ b/apps/documentation/messages/en.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "site_title": "Devflare Docs", + "aria_close_navigation": "Close navigation", + "aria_toggle_navigation": "Toggle navigation", + "theme_label": "Theme", + "theme_auto": "Auto", + "theme_auto_description": "Follows your system setting until you switch.", + "theme_manual_description": "Manual theme override is active.", + "language_label": "Language", + "language_change": "Change language", + "theme_light": "Light", + "theme_dark": "Dark", + "theme_switch_to_light": "Switch to light theme", + "theme_switch_to_dark": "Switch to dark theme", + "theme_reset_to_auto": "Return to the system theme", + "nav_home": "Home", + "nav_docs_overview": "Docs overview", + "nav_documentation_aria": "Documentation", + "article_page_title": "{title} ยท Devflare Docs", + "article_documentation_overview": "Documentation overview", + "article_on_this_page": "On this page", + "article_previous": "Previous", + "article_next": "Next", + "code_copy": "Copy", + "code_copied": "Copied", + "cta_default_eyebrow": "Fastest start", + "cta_open_guide": "Open the fast-start guide", + "category_label": "Category", + "home_title": "Devflare Docs", + "home_meta_description": "Documentation for Devflare: build a first Cloudflare Worker, see what the library gives you, and jump straight to bindings, testing, previews, and frameworks when you need them.", + "home_hero_eyebrow": "Devflare documentation", + "home_hero_title": "Build Cloudflare apps with less setup drag.", + "home_hero_description": "Start with one Worker. Add bindings, testing, previews, and framework support only when the project actually needs them.", + "home_cta_eyebrow": "Most clicked next step", + "home_cta_meta": "", + "home_cta_title": "Build your first Worker now โ€” one config, one handler, one honest test harness.", + "home_cta_description": "No framework required, typed env in one command, and a real result before the deeper lanes matter.", + "home_cta_highlight_no_framework": "Worker-first by default", + "home_cta_highlight_typed_env": "Typed env from real config", + "home_cta_highlight_real_flow": "Bindings, testing, and previews when you need them", + "home_primary_cta": "Build your first Worker", + "home_hero_support": "Need the context first?", + "home_hero_support_link": "See why Devflare exists", + "home_pill_browse_docs": "Start the docs", + "home_pill_why_helps": "See why Devflare helps", + "home_signal_authored_label": "Authored first", + "home_signal_authored_title": "Keep code and config readable", + "home_signal_authored_desc": "Write `fetch.ts` and `devflare.config.ts` first. Devflare handles the awkward wiring underneath them.", + "home_signal_workflow_label": "Workflow aware", + "home_signal_workflow_title": "Stay worker-only until the package truly needs Vite", + "home_signal_workflow_desc": "The docs show when the same commands stay worker-only and when a Vite host actually becomes worth it.", + "home_signal_cloudflare_label": "Still Cloudflare", + "home_signal_cloudflare_title": "Stay close to the platform you actually deploy", + "home_signal_cloudflare_desc": "The output still maps cleanly to Workers, Wrangler, bindings, and real Cloudflare deploy targets.", + "home_starter_eyebrow": "The shortest useful path", + "home_starter_title": "You only need a few moving pieces to feel it click.", + "home_starter_description": "The important part is not more abstraction. It is a shorter path from authored files to a deployable Cloudflare app.", + "home_starter_snippet_label": "Starter commands", + "home_starter_snippet_title": "Three commands to get moving", + "home_starter_body": "You do not need the whole docs tree on day one. Get a Worker running, generate env types, and add the next capability when the code asks for it.", + "home_starter_pill": "See when Vite actually belongs", + "home_library_eyebrow": "What Devflare gives you", + "home_library_title": "The library should stand out before the setup lore does.", + "home_library_description": "These are the capabilities most teams reach for first.", + "home_feature_config_label": "Config", + "home_feature_config_title": "Readable config", + "home_feature_config_description": "Author devflare.config.ts for humans and keep generated Wrangler output in the output lane.", + "home_feature_bindings_label": "Bindings", + "home_feature_bindings_title": "Typed bindings", + "home_feature_bindings_description": "Generate env.d.ts, work with KV, D1, R2, Durable Objects, and queues, and keep the runtime surface aligned.", + "home_feature_testing_label": "Testing", + "home_feature_testing_title": "Runtime-shaped tests", + "home_feature_testing_description": "createTestContext() and cf.* let you test the worker you actually ship, not a hand-made imitation.", + "home_feature_previews_label": "Deploys", + "home_feature_previews_title": "Explicit previews", + "home_feature_previews_description": "Separate preview and production on purpose, with names and cleanup paths that are easy to review.", + "home_feature_composition_label": "Composition", + "home_feature_composition_title": "Worker composition", + "home_feature_composition_description": "Use service bindings and ref() when the app grows into more than one worker.", + "home_feature_frameworks_label": "Frameworks", + "home_feature_frameworks_title": "Frameworks when needed", + "home_feature_frameworks_description": "Stay worker-first until the package genuinely needs Vite or SvelteKit around it.", + "home_actions_eyebrow": "Take action", + "home_actions_title": "Pick the next thing you want to do.", + "home_actions_description": "These are the fastest useful paths through the docs.", + "home_read_eyebrow": "Start here", + "home_read_title": "Read these three pages first", + "home_read_description": "Understand the shape, ship one Worker, then see how the HTTP split keeps the app readable as it grows.", + "home_quicklinks_eyebrow": "Jump to the right lane", + "home_quicklinks_title": "Past the first Worker? Open only the docs you need.", + "home_quicklinks_description": "The homepage stays short. The deeper docs are still there when you need CLI guidance, framework choices, config details, or previews.", + "home_quicklinks_pill": "Open the docs", + "guide_label": "Guide", + "platform_eyebrow": "Cloudflare connection", + "platform_title": "Author the small layer. Let Devflare connect the rest.", + "platform_description": "Keep the files you write easy to scan, then let Devflare wire the typed env, local bindings, preview flow, and Cloudflare-shaped output underneath them.", + "platform_snippet_label": "Authored files", + "platform_snippet_title": "Keep the inputs obvious", + "platform_readable_flow": "Readable flow:", + "platform_connection_line": "fetch.ts โ†’ devflare.config.ts โ†’ typed env โ†’ local dev โ†’ Cloudflare", + "platform_typed_label": "Typed contract", + "platform_typed_title": "Generate `env.d.ts` once", + "platform_typed_desc": "Keep bindings aligned with the app instead of hand-editing runtime types.", + "platform_dev_label": "Single dev loop", + "platform_dev_title": "Run one local system", + "platform_dev_desc": "The worker and optional app shell stay in one understandable loop.", + "platform_ship_label": "Explicit shipping", + "platform_ship_title": "Separate previews from production", + "platform_ship_desc": "Named scopes make release behavior boring, intentional, and easy to reason about.", + "docs_page_title": "Documentation overview ยท Devflare Docs", + "docs_meta_description": "Start here if you are new to Devflare: understand the value, ship a first Worker quickly, learn when to stay worker-first or reach for a framework host, and use the binding reference library when you need exact per-binding details.", + "docs_header_eyebrow": "Documentation overview", + "docs_header_title": "Start with the first win, then branch into the rest of Devflare when you need it", + "docs_header_description": "This site is organized for someone who is still deciding whether Devflare feels worth it. Start with the value story, build one small Worker, learn when to stay worker-first or reach for a framework host, and then use the dedicated binding reference pages when you need exact details about a Cloudflare surface.", + "docs_stat_pages_label": "Pages", + "docs_stat_pages_value": "{count} short reads instead of one giant \"learn the whole stack first\" page.", + "docs_stat_path_label": "New-user path", + "docs_stat_path_value": "{count} pages covering Why Devflare, your first Worker, and the HTTP split that keeps apps readable.", + "docs_stat_category_label": "Largest category", + "docs_stat_category_value": "{count} pages, so browsing stays split into manageable lanes.", + "docs_starter_kicker": "Start here if you are new", + "docs_step_label": "Step {number}", + "docs_deeper_kicker": "Then go deeper", + "docs_commands_title": "The 10-minute starter path", + "docs_value_eyebrow": "Why Devflare feels worth it", + "docs_value_title": "The docs now surface the value before the deeper caveats", + "docs_value_fast_label": "Fast first success", + "docs_value_fast_title": "Build one understandable Worker before the advanced pages matter", + "docs_value_fast_body": "The overview leads with one short onboarding path so a new user gets a win before the advanced pages show up.", + "docs_value_worker_label": "Worker-first by default", + "docs_value_worker_title": "Keep the small packages small", + "docs_value_worker_body": "Devflare starts with a worker-first mental model and only layers in Vite, SvelteKit, or broader hosting when the package actually needs it.", + "docs_value_impl_label": "Implementation-backed", + "docs_value_impl_title": "Stay close to how Cloudflare really behaves", + "docs_value_impl_body": "The guidance stays grounded in authored config, repo examples, and actual runtime or deploy behavior instead of drifting into theory.", + "docs_value_nav_label": "Manageable navigation", + "docs_value_nav_title": "No category dumps a whole subsystem on you at once", + "docs_value_nav_body": "The docs are split into small lanes so browsing does not turn into a second curriculum." +} \ No newline at end of file diff --git a/apps/documentation/package.json b/apps/documentation/package.json new file mode 100644 index 0000000..d4c718c --- /dev/null +++ b/apps/documentation/package.json @@ -0,0 +1,47 @@ +{ + "name": "documentation", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "llm:generate": "bun ./scripts/generate-llm-documents.ts", + "social:generate": "bun ./scripts/generate-social-cards.ts", + "dev": "bun run social:generate && bun run llm:generate && bunx --bun devflare dev", + "build": "bun run social:generate && bun run llm:generate && bunx --bun devflare build", + "deploy": "bun run social:generate && bun run llm:generate && bunx devflare deploy", + "deploy:preview": "bun run social:generate && bun run llm:generate && bunx devflare deploy --preview", + "paraglide:compile": "bun ./scripts/compile-paraglide.ts", + "prepare": "bun run paraglide:compile && bun run llm:generate && svelte-kit sync || echo ''", + "check": "bun run paraglide:compile && bun run social:generate && bun run llm:generate && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "bun run paraglide:compile && bun run social:generate && bun run llm:generate && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "types": "bunx --bun devflare types" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@iconify-json/fluent": "^1.2.44", + "@iconify-json/logos": "^1.2.11", + "@iconify-json/material-icon-theme": "^1.2.58", + "@iconify-json/twemoji": "^1.2.5", + "@iconify/tailwind4": "^1.2.3", + "@inlang/paraglide-js": "^2.15.2", + "@sveltejs/adapter-cloudflare": "^7.2.8", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/prismjs": "^1.26.6", + "devflare": "workspace:*", + "puppeteer-core": "^24.40.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7", + "wrangler": "^4.81.0" + }, + "dependencies": { + "@chenglou/pretext": "^0.0.5", + "floating-runes": "^1.4.0", + "prismjs": "^1.30.0" + } +} diff --git a/apps/documentation/paraglide-routing.ts b/apps/documentation/paraglide-routing.ts new file mode 100644 index 0000000..0952fdf --- /dev/null +++ b/apps/documentation/paraglide-routing.ts @@ -0,0 +1,35 @@ +import { docs } from './src/lib/docs/content' + +interface UrlPattern { + pattern: string + localized: Array<[string, string]> +} + +function createLocalizedPaths(canonicalPath: string): Array<[string, string]> { + return [['en', canonicalPath]] +} + +const documentationDocUrlPatterns: UrlPattern[] = docs.map((doc) => { + const canonicalPath = `/docs/${doc.slug}` + + return { + pattern: canonicalPath, + localized: createLocalizedPaths(canonicalPath) + } +}) + +export const documentationUrlPatterns: UrlPattern[] = [ + { + pattern: '/', + localized: createLocalizedPaths('/') + }, + { + pattern: '/docs', + localized: createLocalizedPaths('/docs') + }, + ...documentationDocUrlPatterns, + { + pattern: '/:path(.*)?', + localized: createLocalizedPaths('/:path(.*)?') + } +] diff --git a/apps/documentation/project.inlang/settings.json b/apps/documentation/project.inlang/settings.json new file mode 100644 index 0000000..acfd0d3 --- /dev/null +++ b/apps/documentation/project.inlang/settings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + }, + "baseLocale": "en", + "locales": [ + "en" + ] +} diff --git a/apps/documentation/scripts/assets/npmjs-logo.png b/apps/documentation/scripts/assets/npmjs-logo.png new file mode 100644 index 0000000..6d43924 Binary files /dev/null and b/apps/documentation/scripts/assets/npmjs-logo.png differ diff --git a/apps/documentation/scripts/compile-paraglide.ts b/apps/documentation/scripts/compile-paraglide.ts new file mode 100644 index 0000000..2370830 --- /dev/null +++ b/apps/documentation/scripts/compile-paraglide.ts @@ -0,0 +1,10 @@ +import { compile } from '@inlang/paraglide-js' +import { documentationUrlPatterns } from '../paraglide-routing' + +await compile({ + project: './project.inlang', + outdir: './src/lib/paraglide', + emitTsDeclarations: true, + strategy: ['url', 'baseLocale'], + urlPatterns: documentationUrlPatterns +}) diff --git a/apps/documentation/scripts/generate-llm-documents.ts b/apps/documentation/scripts/generate-llm-documents.ts new file mode 100644 index 0000000..84cc8e7 --- /dev/null +++ b/apps/documentation/scripts/generate-llm-documents.ts @@ -0,0 +1,9 @@ +import { generateLLMDocuments } from './llm-documents' + +async function main(): Promise { + const result = await generateLLMDocuments() + + console.log(`Generated ${result.outputFiles.join(', ')} in ${result.outputDirs.join(', ')}.`) +} + +await main() \ No newline at end of file diff --git a/apps/documentation/scripts/generate-social-cards.ts b/apps/documentation/scripts/generate-social-cards.ts new file mode 100644 index 0000000..c7c20ae --- /dev/null +++ b/apps/documentation/scripts/generate-social-cards.ts @@ -0,0 +1,9 @@ +import { generateSocialCards } from './social-cards' + +async function main(): Promise { + const result = await generateSocialCards() + + console.log(`Generated ${result.outputFiles.length} social cards in ${result.outputDir}.`) +} + +await main() diff --git a/apps/documentation/scripts/llm-documents.ts b/apps/documentation/scripts/llm-documents.ts new file mode 100644 index 0000000..a8a7855 --- /dev/null +++ b/apps/documentation/scripts/llm-documents.ts @@ -0,0 +1,63 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildLLMDocument, buildStrictLLMDocument } from '../src/lib/docs/llm' + +const GENERATED_LLM_FILE_NAMES = ['LLM.md', 'LLM.txt'] as const +type GeneratedLLMFileName = (typeof GENERATED_LLM_FILE_NAMES)[number] + +const LLM_SOURCE_MATCHERS = [ + '/src/lib/docs/', + '/scripts/llm-documents.ts', + '/scripts/generate-llm-documents.ts' +] as const + +export interface GenerateLLMDocumentsResult { + document: string + documents: Record + outputDirs: readonly string[] + outputFiles: readonly string[] +} + +export function getDocumentationStaticDir(): string { + const scriptDir = dirname(fileURLToPath(import.meta.url)) + return resolve(scriptDir, '../static') +} + +export function getGeneratedLLMOutputDirs(): readonly string[] { + return [getDocumentationStaticDir()] +} + +export function shouldRegenerateLLMDocuments(filePath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/') + + return LLM_SOURCE_MATCHERS.some((matcher) => normalizedFilePath.includes(matcher)) +} + +export async function generateLLMDocuments(options: { + outputDirs?: readonly string[] +} = {}): Promise { + const outputDirs = options.outputDirs ?? getGeneratedLLMOutputDirs() + const documents: Record = { + 'LLM.md': `${buildLLMDocument().trimEnd()}\n`, + 'LLM.txt': `${buildStrictLLMDocument().trimEnd()}\n` + } + + await Promise.all( + outputDirs.flatMap((outputDir) => { + return [ + mkdir(outputDir, { recursive: true }), + ...GENERATED_LLM_FILE_NAMES.map((fileName) => { + return writeFile(resolve(outputDir, fileName), documents[fileName], 'utf8') + }) + ] + }) + ) + + return { + document: documents['LLM.md'], + documents, + outputDirs, + outputFiles: GENERATED_LLM_FILE_NAMES + } +} \ No newline at end of file diff --git a/apps/documentation/scripts/social-cards.ts b/apps/documentation/scripts/social-cards.ts new file mode 100644 index 0000000..bd54c0d --- /dev/null +++ b/apps/documentation/scripts/social-cards.ts @@ -0,0 +1,504 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import puppeteer, { type Browser } from 'puppeteer-core' +import { createHash } from 'node:crypto' +import { access, mkdir, readFile, readdir, rm } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render } from 'svelte/server' +import { createServer } from 'vite' +import { docs } from '../src/lib/docs/content' +import { + DEFAULT_SOCIAL_CARD_TITLE, + DEFAULT_SOCIAL_DESCRIPTION, + getSocialCardPath, + getSocialDescription +} from '../src/lib/site/social' + +export interface SocialCardPage { + path: string + title: string + description: string +} + +export interface SocialCardAssets { + logoSvg?: string + npmLogoPng?: Buffer +} + +export interface RenderSocialCardPngInput { + html: string + outputPath: string + page: SocialCardPage +} + +export type RenderSocialCardPng = (input: RenderSocialCardPngInput) => Promise + +export interface GenerateSocialCardsOptions extends SocialCardAssets { + force?: boolean + outputDir?: string + pages?: readonly SocialCardPage[] + renderPng?: RenderSocialCardPng +} + +export interface GenerateSocialCardsResult { + outputDir: string + outputFiles: string[] + pages: readonly SocialCardPage[] +} + +interface ResolvedSocialCardAssets { + logoDataUrl: string + npmLogoDataUrl: string +} + +interface SocialCardBlob { + x: number + y: number + width: number + height: number + rotation: number + opacity: number + tone: 'orange' | 'amber' +} + +interface SocialCardTemplateRenderer { + renderHtml(page: SocialCardPage): string + close(): Promise +} + +const CARD_WIDTH = 1200 +const CARD_HEIGHT = 630 +const MANIFEST_FILENAME = '.manifest.json' +const SOCIAL_CARD_COMPONENT_PATH = '/src/lib/social-card/SocialCard.svelte' + +function randomBetween(min: number, max: number): number { + return Math.round(min + Math.random() * (max - min)) +} + +function createBackgroundBlobs(): SocialCardBlob[] { + return [ + { + x: 792 + randomBetween(-38, 44), + y: 18 + randomBetween(-26, 30), + width: 440 + randomBetween(-34, 38), + height: 240 + randomBetween(-22, 24), + rotation: randomBetween(-9, 9), + opacity: randomBetween(72, 88) / 100, + tone: 'orange' + }, + { + x: 712 + randomBetween(-54, 60), + y: 220 + randomBetween(-34, 38), + width: 560 + randomBetween(-44, 52), + height: 300 + randomBetween(-28, 30), + rotation: randomBetween(-13, 13), + opacity: randomBetween(68, 82) / 100, + tone: 'amber' + }, + { + x: 936 + randomBetween(-44, 36), + y: 424 + randomBetween(-30, 26), + width: 360 + randomBetween(-30, 34), + height: 230 + randomBetween(-24, 24), + rotation: randomBetween(-10, 10), + opacity: randomBetween(52, 68) / 100, + tone: 'orange' + } + ] +} + +function getScriptDir(): string { + return dirname(fileURLToPath(import.meta.url)) +} + +export function getDocumentationAppDir(): string { + return resolve(getScriptDir(), '..') +} + +export function getDocumentationStaticDir(): string { + return resolve(getDocumentationAppDir(), 'static') +} + +export function getSocialCardsOutputDir(): string { + return resolve(getDocumentationStaticDir(), 'social-cards') +} + +export function getNpmLogoAssetPath(): string { + return resolve(getScriptDir(), 'assets/npmjs-logo.png') +} + +function getSocialCardsManifestPath(outputDir: string): string { + return resolve(outputDir, MANIFEST_FILENAME) +} + +function toDataUrl(mimeType: string, bytes: string | Buffer): string { + return `data:${mimeType};base64,${Buffer.from(bytes).toString('base64')}` +} + +function toCardOutputPath(outputDir: string, cardPath: string): string { + const relativePath = cardPath.replace(/^\/social-cards\/?/, '') + const outputPath = resolve(outputDir, relativePath) + const normalizedOutputDir = resolve(outputDir) + const outputRelativePath = relative(normalizedOutputDir, outputPath) + + if (outputRelativePath.startsWith('..') || isAbsolute(outputRelativePath)) { + throw new Error(`Refusing to write social card outside ${normalizedOutputDir}: ${outputPath}`) + } + + return outputPath +} + +function hashContent(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex') +} + +async function createSocialCardsFingerprint( + pages: readonly SocialCardPage[], + assets: ResolvedSocialCardAssets +): Promise { + const [scriptSource, componentSource] = await Promise.all([ + readFile(fileURLToPath(import.meta.url), 'utf8'), + readFile(resolve(getDocumentationAppDir(), 'src/lib/social-card/SocialCard.svelte'), 'utf8') + ]) + + return hashContent( + JSON.stringify({ + version: 2, + cardSize: [CARD_WIDTH, CARD_HEIGHT], + pages, + logoDataUrlHash: hashContent(assets.logoDataUrl), + npmLogoDataUrlHash: hashContent(assets.npmLogoDataUrl), + scriptSourceHash: hashContent(scriptSource), + componentSourceHash: hashContent(componentSource) + }) + ) +} + +async function readManifestFingerprint(outputDir: string): Promise { + try { + const manifest = JSON.parse(await readFile(getSocialCardsManifestPath(outputDir), 'utf8')) + + return typeof manifest?.fingerprint === 'string' ? manifest.fingerprint : undefined + } catch { + return undefined + } +} + +async function writeManifest(outputDir: string, fingerprint: string): Promise { + await Bun.write( + getSocialCardsManifestPath(outputDir), + `${JSON.stringify( + { + fingerprint, + generatedAt: new Date().toISOString() + }, + null, + 2 + )}\n` + ) +} + +async function allFilesExist(paths: readonly string[]): Promise { + const checks = await Promise.all(paths.map((path) => pathExists(path))) + + return checks.every(Boolean) +} + +async function resolveSocialCardAssets( + options: SocialCardAssets = {} +): Promise { + const logoSvg = + options.logoSvg ?? (await readFile(resolve(getDocumentationStaticDir(), 'devflare-logo.svg'), 'utf8')) + const npmLogoPng = options.npmLogoPng ?? (await readFile(getNpmLogoAssetPath())) + + return { + logoDataUrl: toDataUrl('image/svg+xml', logoSvg), + npmLogoDataUrl: toDataUrl('image/png', npmLogoPng) + } +} + +async function createSocialCardTemplateRenderer( + assets: ResolvedSocialCardAssets +): Promise { + const vite = await createServer({ + appType: 'custom', + configFile: false, + logLevel: 'error', + root: getDocumentationAppDir(), + server: { + hmr: false, + middlewareMode: true + }, + plugins: [ + svelte({ + configFile: false, + compilerOptions: { + dev: false + } + }) + ] + }) + const componentModule = await vite.ssrLoadModule(SOCIAL_CARD_COMPONENT_PATH) + const SocialCard = componentModule.default + + return { + renderHtml(page) { + const rendered = render(SocialCard, { + props: { + title: page.title, + description: page.description, + logoDataUrl: assets.logoDataUrl, + npmLogoDataUrl: assets.npmLogoDataUrl, + blobs: createBackgroundBlobs() + } + }) + + return ` + + + + + ${rendered.head} + +${rendered.body} +` + }, + close() { + return vite.close() + } + } +} + +export function createSocialCardPages(): SocialCardPage[] { + return [ + { + path: getSocialCardPath(undefined), + title: DEFAULT_SOCIAL_CARD_TITLE, + description: DEFAULT_SOCIAL_DESCRIPTION + }, + ...docs.map((doc) => ({ + path: getSocialCardPath(doc), + title: doc.navTitle, + description: getSocialDescription(doc) + })) + ] +} + +export async function renderSocialCardHtml( + page: SocialCardPage, + options: SocialCardAssets = {} +): Promise { + const assets = await resolveSocialCardAssets(options) + const renderer = await createSocialCardTemplateRenderer(assets) + + try { + return renderer.renderHtml(page) + } finally { + await renderer.close() + } +} + +async function pathExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +async function collectVersionedExecutableCandidates( + parentDir: string | undefined, + directoryPrefix: string, + executableParts: string[] +): Promise { + if (!parentDir) { + return [] + } + + try { + const entries = await readdir(parentDir, { withFileTypes: true }) + + return entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith(directoryPrefix)) + .sort((first, second) => + second.name.localeCompare(first.name, undefined, { numeric: true, sensitivity: 'base' }) + ) + .map((entry) => join(parentDir, entry.name, ...executableParts)) + } catch { + return [] + } +} + +async function getChromiumExecutableCandidates(): Promise { + const candidates = [ + process.env.DEVFLARE_SOCIAL_CARD_CHROME, + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROME_PATH + ].filter((candidate): candidate is string => Boolean(candidate)) + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA + const userProfile = process.env.USERPROFILE + + candidates.push( + ...(await collectVersionedExecutableCandidates(localAppData && join(localAppData, 'ms-playwright'), 'chromium-', [ + 'chrome-win64', + 'chrome.exe' + ])), + ...(await collectVersionedExecutableCandidates( + localAppData && join(localAppData, 'xdg.cache', '.wrangler', 'chrome'), + 'win64-', + ['chrome-win64', 'chrome.exe'] + )), + ...(await collectVersionedExecutableCandidates( + userProfile && join(userProfile, '.cache', 'puppeteer', 'chrome'), + 'win64-', + ['chrome-win64', 'chrome.exe'] + )), + join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe') + ) + } else if (process.platform === 'darwin') { + const home = process.env.HOME + + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ...(await collectVersionedExecutableCandidates( + home && join(home, 'Library', 'Caches', 'ms-playwright'), + 'chromium-', + ['chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'] + )), + ...(await collectVersionedExecutableCandidates(home && join(home, '.cache', 'puppeteer', 'chrome'), 'mac-', [ + 'chrome-mac-x64', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing' + ])) + ) + } else { + const home = process.env.HOME + + candidates.push( + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + ...(await collectVersionedExecutableCandidates(home && join(home, '.cache', 'ms-playwright'), 'chromium-', [ + 'chrome-linux', + 'chrome' + ])), + ...(await collectVersionedExecutableCandidates(home && join(home, '.cache', 'puppeteer', 'chrome'), 'linux-', [ + 'chrome-linux64', + 'chrome' + ])) + ) + } + + return candidates +} + +export async function findChromiumExecutablePath(): Promise { + for (const candidate of await getChromiumExecutableCandidates()) { + if (await pathExists(candidate)) { + return candidate + } + } + + throw new Error( + 'Unable to find a Chromium-compatible browser for social card rendering. Install Chrome/Chromium, run Playwright/Puppeteer browser install, or set DEVFLARE_SOCIAL_CARD_CHROME to the browser executable.' + ) +} + +async function createBrowserPngRenderer(): Promise<{ + renderPng: RenderSocialCardPng + close(): Promise +}> { + const executablePath = await findChromiumExecutablePath() + const browser: Browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }) + const page = await browser.newPage() + + await page.setViewport({ + width: CARD_WIDTH, + height: CARD_HEIGHT, + deviceScaleFactor: 1 + }) + + return { + async renderPng({ html, outputPath }) { + await page.setContent(html, { waitUntil: 'load' }) + await page.evaluate(() => document.fonts.ready) + await page.screenshot({ + path: outputPath, + type: 'png', + clip: { + x: 0, + y: 0, + width: CARD_WIDTH, + height: CARD_HEIGHT + } + }) + }, + async close() { + await page.close() + await browser.close() + } + } +} + +export async function generateSocialCards( + options: GenerateSocialCardsOptions = {} +): Promise { + const outputDir = options.outputDir ?? getSocialCardsOutputDir() + const pages = options.pages ?? createSocialCardPages() + const assets = await resolveSocialCardAssets(options) + const outputFiles = pages.map((page) => toCardOutputPath(outputDir, page.path)) + const fingerprint = await createSocialCardsFingerprint(pages, assets) + const force = options.force ?? process.env.DEVFLARE_SOCIAL_CARDS_FORCE === '1' + + if (!force && (await readManifestFingerprint(outputDir)) === fingerprint && (await allFilesExist(outputFiles))) { + return { + outputDir, + outputFiles, + pages + } + } + + const templateRenderer = await createSocialCardTemplateRenderer(assets) + const browserRenderer = options.renderPng ? undefined : await createBrowserPngRenderer() + const renderPng = options.renderPng ?? browserRenderer?.renderPng + + if (!renderPng) { + throw new Error('Social card renderer was not configured.') + } + + await rm(outputDir, { recursive: true, force: true }) + + try { + for (const [index, page] of pages.entries()) { + const outputPath = outputFiles[index] + const html = templateRenderer.renderHtml(page) + + await mkdir(dirname(outputPath), { recursive: true }) + await renderPng({ html, outputPath, page }) + } + await writeManifest(outputDir, fingerprint) + } finally { + await Promise.all([templateRenderer.close(), browserRenderer?.close()]) + } + + return { + outputDir, + outputFiles, + pages + } +} diff --git a/apps/documentation/src/app.d.ts b/apps/documentation/src/app.d.ts new file mode 100644 index 0000000..33d3bef --- /dev/null +++ b/apps/documentation/src/app.d.ts @@ -0,0 +1,21 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +// DevflareEnv is declared globally by env.d.ts, generated via `bun run types`. + +declare global { + namespace App { + interface Platform { + env: DevflareEnv + context: ExecutionContext + caches: CacheStorage + cf: Record + } + + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + } +} + +export { } diff --git a/apps/documentation/src/app.html b/apps/documentation/src/app.html new file mode 100644 index 0000000..dd97c2e --- /dev/null +++ b/apps/documentation/src/app.html @@ -0,0 +1,22 @@ + + + + + + + + + + %sveltekit.head% + + + +
%sveltekit.body%
+ + diff --git a/apps/documentation/src/hooks.server.ts b/apps/documentation/src/hooks.server.ts new file mode 100644 index 0000000..ad2f1c2 --- /dev/null +++ b/apps/documentation/src/hooks.server.ts @@ -0,0 +1,30 @@ +import type { Handle } from '@sveltejs/kit' +import { sequence } from '@sveltejs/kit/hooks' +import { paraglideMiddleware } from '$lib/paraglide/server' +import { getTextDirection } from '$lib/paraglide/runtime' + +let devflareHandlePromise: Promise | null = null + +const handleDevflarePlatform: Handle = async ({ event, resolve }) => { + if (import.meta.env.DEV && process.env.DEVFLARE_DEV === 'true') { + devflareHandlePromise ??= import('../../../packages/devflare/src/sveltekit/index') + .then((module) => module.handle as Handle) + + const devflareHandle = await devflareHandlePromise + return devflareHandle({ event, resolve }) + } + + return resolve(event) +} + +const handleDocumentLocale: Handle = ({ event, resolve }) => + paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { + event.request = localizedRequest + + return resolve(event, { + transformPageChunk: ({ html }) => + html.replace('%paraglide.lang%', locale).replace('%paraglide.dir%', getTextDirection(locale)) + }) + }) + +export const handle: Handle = sequence(handleDevflarePlatform, handleDocumentLocale) diff --git a/apps/documentation/src/hooks.ts b/apps/documentation/src/hooks.ts new file mode 100644 index 0000000..5b08f00 --- /dev/null +++ b/apps/documentation/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Reroute, Transport } from '@sveltejs/kit' +import { deLocalizeUrl } from '$lib/paraglide/runtime' + +const routeAliases = { + '/llm.md': '/LLM.md', + '/llm.txt': '/LLM.txt', + '/docs/workflow-modes': '/docs/vite-standalone', + '/de/docs/workflow-modi': '/de/docs/vite-standalone', + '/dk/docs/workflow-tilstande': '/dk/docs/vite-standalone' +} as const satisfies Record + +function resolveRouteAlias(pathname: string): string | undefined { + return routeAliases[pathname as keyof typeof routeAliases] +} + +export const reroute: Reroute = (request) => { + const pathname = deLocalizeUrl(request.url).pathname + + return resolveRouteAlias(pathname) ?? pathname +} + +export const transport: Transport = {} diff --git a/apps/documentation/src/lib/components/article/Article.svelte b/apps/documentation/src/lib/components/article/Article.svelte new file mode 100644 index 0000000..b0df3c7 --- /dev/null +++ b/apps/documentation/src/lib/components/article/Article.svelte @@ -0,0 +1,335 @@ + + +
+
+ +
+
+
+ + + +
+
+ + {#if doc.headerSupport || doc.headerCloudflareDocs} +
+ {#if doc.headerSupport} + + {/if} + + {#if doc.headerCloudflareDocs} + + {doc.headerCloudflareDocs.label ?? 'Cloudflare Documentation'} + + + {/if} +
+ {/if} +
+ +
+

+ {#if doc.headerCloudflareDocs} +

+ +

+ {/if} + {#if !doc.summaryHidden} +

+ {/if} + {#if !doc.descriptionHidden} +

+ {/if} +
+ +
+ {#each doc.facts as fact} +
+
+
+
+ {/each} +
+
+ + {#each doc.sections as section, index} +
+ + + {#if section.paragraphs} +
+ {#each section.paragraphs as paragraph} +

+ {/each} +
+ {/if} + + {#if section.cards} +
+ {#each section.cards as card} + {#if card.href} + + {:else} + + {/if} + {/each} +
+ {/if} + + {#if section.bullets} + + {/if} + + {#if section.steps} + + {/if} + + {#if section.table} +
+
+ + + + {#each section.table.headers as header} + + {/each} + + + + {#each section.table.rows as row} + + {#each row as cell} + + {/each} + + {/each} + +
+
+
+ {/if} + + {#if section.callouts} +
+ {#each section.callouts as callout} + + {/each} +
+ {/if} + + {#if section.snippets} +
+ {#each section.snippets as snippet} + + {/each} +
+ {/if} +
+ {/each} + + {#if !doc.articleNavigationHidden && (previous || next)} +
+ {#if previous} + + {/if} + + {#if next} + + {/if} +
+ {/if} +
+ + { + tocContentOffset = offset + }} + /> +
diff --git a/apps/documentation/src/lib/components/article/BulletList.svelte b/apps/documentation/src/lib/components/article/BulletList.svelte new file mode 100644 index 0000000..807d1ee --- /dev/null +++ b/apps/documentation/src/lib/components/article/BulletList.svelte @@ -0,0 +1,24 @@ + + +
    + {#each items as item} +
  • + + +
  • + {/each} +
diff --git a/apps/documentation/src/lib/components/article/Callout.svelte b/apps/documentation/src/lib/components/article/Callout.svelte new file mode 100644 index 0000000..f874af5 --- /dev/null +++ b/apps/documentation/src/lib/components/article/Callout.svelte @@ -0,0 +1,72 @@ + + +
+ +
+
+ +

+
+
+ {#each body as paragraph} +

+ {/each} +
+ {#if cta} +
+
+
+

Next step

+ {#if cta.description} +

+ +

+ {/if} +
+ +
+
+ {/if} +
+
diff --git a/apps/documentation/src/lib/components/article/FloatingToc.svelte b/apps/documentation/src/lib/components/article/FloatingToc.svelte new file mode 100644 index 0000000..9653ae4 --- /dev/null +++ b/apps/documentation/src/lib/components/article/FloatingToc.svelte @@ -0,0 +1,411 @@ + + + diff --git a/apps/documentation/src/lib/components/article/StepList.svelte b/apps/documentation/src/lib/components/article/StepList.svelte new file mode 100644 index 0000000..026860b --- /dev/null +++ b/apps/documentation/src/lib/components/article/StepList.svelte @@ -0,0 +1,24 @@ + + +
    + {#each items as item, index} +
  1. +
    {index + 1}
    +

    +
  2. + {/each} +
diff --git a/apps/documentation/src/lib/components/cards/Badge.svelte b/apps/documentation/src/lib/components/cards/Badge.svelte new file mode 100644 index 0000000..880044e --- /dev/null +++ b/apps/documentation/src/lib/components/cards/Badge.svelte @@ -0,0 +1,35 @@ + + + + {label} + diff --git a/apps/documentation/src/lib/components/cards/CategoryCard.svelte b/apps/documentation/src/lib/components/cards/CategoryCard.svelte new file mode 100644 index 0000000..8a8279f --- /dev/null +++ b/apps/documentation/src/lib/components/cards/CategoryCard.svelte @@ -0,0 +1,29 @@ + + +
+
+

{m.category_label()}

+

+
+

+ +
+ {#each category.items as doc} + + {/each} +
+
diff --git a/apps/documentation/src/lib/components/cards/FeatureCard.svelte b/apps/documentation/src/lib/components/cards/FeatureCard.svelte new file mode 100644 index 0000000..1e5c660 --- /dev/null +++ b/apps/documentation/src/lib/components/cards/FeatureCard.svelte @@ -0,0 +1,41 @@ + + +
+ {#if label} +

+ {/if} + + {#if description} +

+ {/if} +
diff --git a/apps/documentation/src/lib/components/cards/LinkCard.svelte b/apps/documentation/src/lib/components/cards/LinkCard.svelte new file mode 100644 index 0000000..02e60d5 --- /dev/null +++ b/apps/documentation/src/lib/components/cards/LinkCard.svelte @@ -0,0 +1,108 @@ + + + + {#if label} +

+ {#if labelTooltip} + + + + {:else} + + {/if} +

+ {/if} + +
+ + {#if meta} + + {/if} +
+ + {#if description} +

+ {/if} +
diff --git a/apps/documentation/src/lib/components/cards/StatCard.svelte b/apps/documentation/src/lib/components/cards/StatCard.svelte new file mode 100644 index 0000000..4235db4 --- /dev/null +++ b/apps/documentation/src/lib/components/cards/StatCard.svelte @@ -0,0 +1,25 @@ + + +
+

{label}

+

{value}

+ {#if description} +

{description}

+ {/if} +
diff --git a/apps/documentation/src/lib/components/code/Block.svelte b/apps/documentation/src/lib/components/code/Block.svelte new file mode 100644 index 0000000..fe69ddb --- /dev/null +++ b/apps/documentation/src/lib/components/code/Block.svelte @@ -0,0 +1,116 @@ + + + +
+
1 ? '' : 'docs-code-panel-border border-b'}`}> +
+
+
+

{normalized.title}

+ + {#if normalized.description} +

{normalized.description}

+ {/if} +
+ + {#if showStandaloneMeta && currentFile} +
+ + Code sample type: {currentFile.languageLabel} +
+ {/if} +
+
+ + {#if normalized.files.length > 1} +
+ +
+ {/if} +
+ +
+ {#if normalized.hasStructure} + + {/if} + + {#if currentFile} + + {/if} +
+
diff --git a/apps/documentation/src/lib/components/code/Inline.svelte b/apps/documentation/src/lib/components/code/Inline.svelte new file mode 100644 index 0000000..a9bbac0 --- /dev/null +++ b/apps/documentation/src/lib/components/code/Inline.svelte @@ -0,0 +1,71 @@ + + + diff --git a/apps/documentation/src/lib/components/code/Pane.svelte b/apps/documentation/src/lib/components/code/Pane.svelte new file mode 100644 index 0000000..dfecd70 --- /dev/null +++ b/apps/documentation/src/lib/components/code/Pane.svelte @@ -0,0 +1,414 @@ + + +
+ + +
{ + hideIntellisense() + activeIntellisenseAnchor = undefined + }} + > +
+ +
+
+
diff --git a/apps/documentation/src/lib/components/code/Tabs.svelte b/apps/documentation/src/lib/components/code/Tabs.svelte new file mode 100644 index 0000000..6a5e8d7 --- /dev/null +++ b/apps/documentation/src/lib/components/code/Tabs.svelte @@ -0,0 +1,43 @@ + + + +
+ {#each files as file} + {@const active = file.path === activeFile} + + {/each} +
diff --git a/apps/documentation/src/lib/components/code/Tree.svelte b/apps/documentation/src/lib/components/code/Tree.svelte new file mode 100644 index 0000000..8b83b5f --- /dev/null +++ b/apps/documentation/src/lib/components/code/Tree.svelte @@ -0,0 +1,137 @@ + + + diff --git a/apps/documentation/src/lib/components/code/block.ts b/apps/documentation/src/lib/components/code/block.ts new file mode 100644 index 0000000..6bbfc4f --- /dev/null +++ b/apps/documentation/src/lib/components/code/block.ts @@ -0,0 +1,117 @@ +import type { DocCodeFile, DocCodeSnippet } from '$lib/docs/types' +import { inferSnippetPath } from './block/config' +import { highlightCodeLines } from './block/highlight' +import { + resolveFileIconClass, + resolveLanguage, + resolveLanguageLabel, + resolveMetaIconClass +} from './block/language' +import { extendFocusLines, getFirstLine, getLineState } from './block/lines' +import { basename, normalizePath } from './block/path' +import { + normalizeStructure, + resolveInitialActiveFile, + resolveStructureEntries +} from './block/structure' +import type { NormalizedCodeFile, NormalizedCodeSnippet } from './block/types' + +export type { + LineState, + NormalizedCodeFile, + NormalizedCodeLine, + NormalizedCodeSnippet, + TreeNodeState +} from './block/types' + +export function normalizeSnippet(snippet: DocCodeSnippet): NormalizedCodeSnippet { + const files = snippet.files?.length + ? snippet.files.map((file, index) => normalizeFile(file, snippet, index)) + : [normalizeSingleFileSnippet(snippet)] + const activeFile = resolveInitialActiveFile(snippet.activeFile, files) + const structureEntries = resolveStructureEntries(snippet, files) + const structure = normalizeStructure(structureEntries, files, activeFile) + + return { + title: snippet.title, + description: snippet.description, + files, + structure, + activeFile, + hasStructure: structure.length > 1 + } +} + +export function getCopyCode(file: NormalizedCodeFile): string { + return file.copyCode +} + +function normalizeSingleFileSnippet(snippet: DocCodeSnippet): NormalizedCodeFile { + const displayPath = snippet.filename ? normalizePath(snippet.filename) : inferSnippetPath(snippet) + const path = displayPath ?? '__snippet__0' + const language = resolveLanguage(snippet.language, displayPath) + const languageLabel = resolveLanguageLabel(snippet.language, displayPath, language) + const code = snippet.code ?? '' + const htmlLines = highlightCodeLines(code, language, displayPath) + const sourceLines = code.split('\n') + + return { + path, + displayPath, + label: displayPath ? basename(displayPath) : snippet.title, + iconClass: resolveFileIconClass(displayPath ?? snippet.filename ?? snippet.title), + metaIconClass: resolveMetaIconClass(displayPath ?? snippet.filename, languageLabel), + language, + languageLabel, + code, + copyCode: code, + lines: htmlLines.map((html, index) => ({ + index: index + 1, + number: index + 1, + html, + text: sourceLines[index] ?? '', + state: 'normal' + })) + } +} + +function normalizeFile( + file: DocCodeFile, + snippet: DocCodeSnippet, + index: number +): NormalizedCodeFile { + const displayPath = file.path ? normalizePath(file.path) : undefined + const path = displayPath ?? `__file__${index}` + const language = resolveLanguage(file.language ?? snippet.language, displayPath) + const languageLabel = resolveLanguageLabel( + file.language ?? snippet.language, + displayPath, + language + ) + const code = file.code + const htmlLines = highlightCodeLines(code, language, displayPath) + const sourceLines = code.split('\n') + const effectiveFocusLines = extendFocusLines(code, file.focusLines) + const firstFocusLine = getFirstLine(effectiveFocusLines) + const startLine = file.startLine ?? 1 + + return { + path, + displayPath, + label: file.label ?? (displayPath ? basename(displayPath) : `File ${index + 1}`), + iconClass: resolveFileIconClass(displayPath ?? file.label ?? `file-${index + 1}`), + metaIconClass: resolveMetaIconClass(displayPath ?? file.label, languageLabel), + language, + languageLabel, + code, + copyCode: file.copyCode ?? code, + firstFocusLine, + lines: htmlLines.map((html, lineIndex) => ({ + index: lineIndex + 1, + number: startLine + lineIndex, + html, + text: sourceLines[lineIndex] ?? '', + state: getLineState(lineIndex + 1, effectiveFocusLines, file.dimLines) + })) + } +} diff --git a/apps/documentation/src/lib/components/code/block/config.ts b/apps/documentation/src/lib/components/code/block/config.ts new file mode 100644 index 0000000..57808e0 --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/config.ts @@ -0,0 +1,351 @@ +import type { DocCodeSnippet, DocCodeTreeEntry } from '$lib/docs/types' +import { basename, normalizePath, toKebabCase } from './path' + +type ConfigPathContext = { + path: string + kind: 'object' | 'array' +} + +const wildcardConfigContainerPatterns = [ + 'env', + 'vars', + 'secrets', + 'bindings.kv', + 'bindings.d1', + 'bindings.r2', + 'bindings.durableObjects', + 'bindings.services', + 'bindings.vectorize', + 'bindings.hyperdrive', + 'bindings.browser', + 'bindings.analyticsEngine', + 'bindings.sendEmail', + 'bindings.queues.producers' +] + +export function inferSnippetPath(snippet: DocCodeSnippet): string | undefined { + const code = snippet.code?.trim() + if (!code) { + return undefined + } + + const language = snippet.language?.trim().toLowerCase() + if (isCommandLanguage(language)) { + return undefined + } + + if (isConfigSnippetCode(code)) { + return 'devflare.config.ts' + } + + if (/from ['"]bun:test['"]/.test(code)) { + return 'tests/worker.test.ts' + } + + const durableObjectClass = code.match( + /\bexport\s+class\s+([A-Z][A-Za-z0-9_]*)\s+extends\s+DurableObject(?:<[^>]+>)?\b/ + )?.[1] + if (durableObjectClass) { + return `src/do/${toKebabCase(durableObjectClass)}.ts` + } + + if (/\bextends\s+WorkerEntrypoint\b/.test(code)) { + return 'src/worker.ts' + } + + if ( + /\bexport\s+(?:async\s+)?function\s+fetch\b/.test(code) || + /\bexport\s+const\s+handle\b/.test(code) + ) { + return 'src/fetch.ts' + } + + if (/\bexport\s+(?:async\s+)?function\s+queue\b/.test(code)) { + return 'src/queue.ts' + } + + if (/\bexport\s+(?:async\s+)?function\s+scheduled\b/.test(code)) { + return 'src/scheduled.ts' + } + + if ( + /\bexport\s+(?:async\s+)?function\s+email\b/.test(code) || + /\bForwardableEmailMessage\b/.test(code) + ) { + return 'src/email.ts' + } + + if (/\bexport\s+(?:async\s+)?function\s+tail\b/.test(code)) { + return 'src/tail.ts' + } + + if (/\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.test(code)) { + return 'src/routes/index.ts' + } + + return undefined +} + +export function isCommandLanguage(language: string | undefined): boolean { + if (!language) { + return false + } + + return ['bash', 'console', 'powershell', 'ps1', 'shell', 'sh', 'zsh'].includes(language) +} + +export function isConfigSnippetCode(code: string): boolean { + return /\bdefineConfig\s*\(/.test(code) || /from ['"]devflare\/config['"]/.test(code) +} + +function matchesConfigPathSegments(path: string, pattern: string): boolean { + const pathSegments = path.split('.') + const patternSegments = pattern.split('.') + + if (pathSegments.length !== patternSegments.length) { + return false + } + + return patternSegments.every((segment, index) => { + return segment === '*' || segment === pathSegments[index] + }) +} + +function matchesConfigPathSuffix(path: string, suffix: string): boolean { + const pathSegments = path.split('.') + const suffixSegments = suffix.split('.') + + if (suffixSegments.length > pathSegments.length) { + return false + } + + const offset = pathSegments.length - suffixSegments.length + + return suffixSegments.every((segment, index) => { + return segment === '*' || segment === pathSegments[offset + index] + }) +} + +function isWildcardConfigContainerPath(path: string): boolean { + return wildcardConfigContainerPatterns.some((pattern) => { + return matchesConfigPathSegments(path, pattern) || matchesConfigPathSuffix(path, pattern) + }) +} + +function resolveConfigPropertyPath(parentPath: string | undefined, propertyName: string): string { + if (!parentPath) { + return propertyName + } + + if (isWildcardConfigContainerPath(parentPath)) { + return `${parentPath}.*` + } + + return `${parentPath}.${propertyName}` +} + +function maskQuotedText(line: string): string { + let masked = '' + let activeQuote: '"' | "'" | '`' | undefined + let escaping = false + + for (let index = 0; index < line.length; index += 1) { + const character = line[index] + const nextCharacter = line[index + 1] + + if (activeQuote) { + if (escaping) { + escaping = false + masked += ' ' + continue + } + + if (character === '\\') { + escaping = true + masked += ' ' + continue + } + + if (character === activeQuote) { + activeQuote = undefined + } + + masked += ' ' + continue + } + + if (character === '/' && nextCharacter === '/') { + masked += ' '.repeat(line.length - index) + break + } + + if (character === '"' || character === "'" || character === '`') { + activeQuote = character + masked += ' ' + continue + } + + masked += character + } + + return masked +} + +function closesOnSameLine( + value: string, + openCharacter: '{' | '[', + closeCharacter: '}' | ']' +): boolean { + let depth = 0 + + for (const character of value) { + if (character === openCharacter) { + depth += 1 + continue + } + + if (character === closeCharacter) { + depth -= 1 + if (depth === 0) { + return true + } + } + } + + return false +} + +export function getConfigLinePropertyPaths(code: string): Array { + const contexts: ConfigPathContext[] = [] + + return code.split('\n').map((line) => { + const maskedLine = maskQuotedText(line) + let workingLine = maskedLine.trimStart() + + while (workingLine.startsWith('}') || workingLine.startsWith(']')) { + contexts.pop() + workingLine = workingLine.slice(1).trimStart() + + if (workingLine.startsWith(',')) { + workingLine = workingLine.slice(1).trimStart() + } + } + + if (!workingLine) { + return undefined + } + + const propertyMatch = workingLine.match(/^[{,(]*\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:/) + if (!propertyMatch) { + if (contexts.at(-1)?.kind === 'array' && workingLine.startsWith('{')) { + const arrayPath = contexts.at(-1)?.path + if (arrayPath) { + contexts.push({ + path: arrayPath, + kind: 'object' + }) + + if (closesOnSameLine(workingLine, '{', '}')) { + contexts.pop() + } + } + } + + return undefined + } + + const propertyName = propertyMatch[1] + const propertyPath = resolveConfigPropertyPath(contexts.at(-1)?.path, propertyName) + const afterColon = workingLine.slice(propertyMatch[0].length).trimStart() + + if (afterColon.startsWith('{')) { + contexts.push({ + path: propertyPath, + kind: 'object' + }) + + if (closesOnSameLine(afterColon, '{', '}')) { + contexts.pop() + } + } else if (afterColon.startsWith('[')) { + contexts.push({ + path: propertyPath, + kind: 'array' + }) + + if (closesOnSameLine(afterColon, '[', ']')) { + contexts.pop() + } + } + + return propertyPath + }) +} + +export function inferConfigContextEntries(code: string): DocCodeTreeEntry[] { + const entries: DocCodeTreeEntry[] = [] + const seenPaths = new Set() + + function addPatternPath(pathPattern: string | undefined): void { + const entry = createStructureEntryFromPattern(pathPattern) + if (!entry || seenPaths.has(entry.path)) { + return + } + + seenPaths.add(entry.path) + entries.push(entry) + } + + for (const match of code.matchAll( + /\b(fetch|worker|queue|scheduled|email|tail)\s*:\s*['"]([^'"]+)['"]/g + )) { + addPatternPath(match[2]) + } + + for (const match of code.matchAll(/\bdurableObjects\s*:\s*['"]([^'"]+)['"]/g)) { + addPatternPath(match[1]) + } + + for (const match of code.matchAll( + /\broutes\s*:\s*\{[\s\S]*?\bdir\s*:\s*['"]([^'"]+)['"][\s\S]*?\}/g + )) { + addPatternPath(match[1]) + } + + return entries +} + +export function createStructureEntryFromPattern( + pathPattern: string | undefined +): DocCodeTreeEntry | undefined { + if (!pathPattern) { + return undefined + } + + const normalizedPattern = normalizePath(pathPattern) + if (!normalizedPattern) { + return undefined + } + + const wildcardIndex = normalizedPattern.search(/[\*\{\[]/) + const path = ( + wildcardIndex === -1 ? normalizedPattern : normalizedPattern.slice(0, wildcardIndex) + ).replace(/\/+$/, '') + + if (!path) { + return undefined + } + + return { + path, + kind: /\.[^/]+$/.test(path) ? 'file' : 'folder' + } +} + +export function isDevflareConfigPath(path: string): boolean { + return /^devflare\.config\.(ts|js|mts|cts|mjs|cjs)$/.test(basename(path)) +} + +export function isTypeAwarePath(path: string): boolean { + return /\.(ts|tsx|mts|cts|svelte)$/.test(path) || isDevflareConfigPath(path) +} diff --git a/apps/documentation/src/lib/components/code/block/highlight.ts b/apps/documentation/src/lib/components/code/block/highlight.ts new file mode 100644 index 0000000..d7bd7ae --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/highlight.ts @@ -0,0 +1,230 @@ +import Prism from 'prismjs' +import type { Grammar, Token as PrismToken, TokenStream } from 'prismjs' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-yaml' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-markdown' +import { resolveIntellisenseEntry } from '$lib/intellisense/registry' +import type { IntellisenseRenderContext } from '$lib/intellisense/types' +import { getConfigLinePropertyPaths, isConfigSnippetCode, isDevflareConfigPath } from './config' + +let intellisenseHookRegistered = false + +let activeIntellisenseRenderContext: IntellisenseRenderContext | undefined + +interface PrismWrapEnvironment { + type: string + content: string + classes: string[] + attributes: Record +} + +function ensureIntellisenseHook(): void { + if (intellisenseHookRegistered) { + return + } + + intellisenseHookRegistered = true + + Prism.hooks.add('wrap', ((environment: PrismWrapEnvironment) => { + if (!activeIntellisenseRenderContext || typeof environment.content !== 'string') { + return + } + + const entry = resolveIntellisenseEntry(environment.content, { + ...activeIntellisenseRenderContext, + tokenType: environment.type + }) + + if (!entry) { + return + } + + environment.attributes ??= {} + environment.attributes['data-intellisense-id'] = entry.id + + if (!environment.classes.includes('docs-code-intellisense-token')) { + environment.classes.push('docs-code-intellisense-token') + } + }) as (environment: PrismWrapEnvironment) => void) +} + +export function highlightCodeLines( + code: string, + language: string, + filePath: string | undefined +): string[] { + ensureIntellisenseHook() + const sourceLines = code.split('\n') + const linePropertyPaths = + isConfigSnippetCode(code) || (filePath ? isDevflareConfigPath(filePath) : false) + ? getConfigLinePropertyPaths(code) + : [] + const grammar = getGrammar(language) + if (!grammar || language === 'plain') { + return sourceLines.map((line, index) => { + return annotateIntellisenseHtml(escapeHtml(line), { + filePath, + language, + code, + lineText: line, + propertyPath: linePropertyPaths[index] + }) + }) + } + + const tokens = Prism.tokenize(code, grammar) + const lines = splitTokenStreamIntoLines(tokens) + + return lines.map((line, index) => { + const tokenStream: TokenStream = line.length > 1 ? line : (line[0] ?? '') + const renderContext: IntellisenseRenderContext = { + filePath, + language, + code, + lineText: sourceLines[index] ?? '', + propertyPath: linePropertyPaths[index] + } + + activeIntellisenseRenderContext = renderContext + + try { + const renderedLine = Prism.Token.stringify(tokenStream, language) + return annotateIntellisenseHtml(renderedLine, renderContext) + } finally { + activeIntellisenseRenderContext = undefined + } + }) +} + +function getGrammar(language: string): Grammar | undefined { + if (language === 'plain') { + return undefined + } + + return Prism.languages[language] +} + +function splitPlainTextIntoLines(code: string): string[] { + return code.split('\n').map((line) => escapeHtml(line)) +} + +function splitTokenStreamIntoLines( + stream: Array +): Array> { + const lines: Array> = [[]] + appendTokenStream(lines, stream) + return lines +} + +function appendTokenStream(lines: Array>, stream: TokenStream): void { + if (Array.isArray(stream)) { + for (const part of stream) { + appendTokenStream(lines, part) + } + return + } + + if (typeof stream === 'string') { + const parts = stream.split('\n') + for (const [index, part] of parts.entries()) { + if (part) { + lines.at(-1)?.push(part) + } + + if (index < parts.length - 1) { + lines.push([]) + } + } + return + } + + const nestedLines = splitNestedTokenLines(stream.content) + for (const [index, nestedLine] of nestedLines.entries()) { + const nestedContent: TokenStream = nestedLine.length > 1 ? nestedLine : (nestedLine[0] ?? '') + lines + .at(-1) + ?.push(new Prism.Token(stream.type, nestedContent, stream.alias, undefined, stream.greedy)) + + if (index < nestedLines.length - 1) { + lines.push([]) + } + } +} + +function splitNestedTokenLines(stream: TokenStream): Array> { + const lines: Array> = [[]] + appendTokenStream(lines, stream) + return lines +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +function annotateIntellisenseHtml(html: string, context: IntellisenseRenderContext): string { + if (!html || !/[A-Za-z@_-]/.test(html)) { + return html + } + + const segments = html.split(/(<[^>]+>)/g) + let tagDepth = 0 + + return segments + .map((segment) => { + if (!segment) { + return segment + } + + if (segment.startsWith('<')) { + if (/^<\//.test(segment)) { + tagDepth = Math.max(0, tagDepth - 1) + return segment + } + + if (!/\/>$/.test(segment)) { + tagDepth += 1 + } + + return segment + } + + return tagDepth === 0 ? annotateIntellisenseTextSegment(segment, context) : segment + }) + .join('') +} + +function annotateIntellisenseTextSegment( + segment: string, + context: IntellisenseRenderContext +): string { + const pattern = + /@[A-Za-z0-9._-]+\/[A-Za-z0-9._/-]+|--[A-Za-z0-9-]+|[A-Za-z_][A-Za-z0-9_]*|[a-z][a-z0-9-]+(?:\/[a-z0-9._-]+)+/g + let result = '' + let lastIndex = 0 + + for (const match of segment.matchAll(pattern)) { + const token = match[0] + const start = match.index ?? 0 + const end = start + token.length + const entry = resolveIntellisenseEntry(token, context) + + result += segment.slice(lastIndex, start) + + if (entry) { + result += `${token}` + } else { + result += token + } + + lastIndex = end + } + + result += segment.slice(lastIndex) + return result +} + +function escapeHtmlAttribute(value: string): string { + return escapeHtml(value).replace(/"/g, '"') +} diff --git a/apps/documentation/src/lib/components/code/block/language.ts b/apps/documentation/src/lib/components/code/block/language.ts new file mode 100644 index 0000000..850b9ce --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/language.ts @@ -0,0 +1,307 @@ +import { basename, normalizePath } from './path' + +const languageAliases: Record = { + bash: 'bash', + html: 'markup', + json: 'json', + jsonc: 'json', + md: 'markdown', + markdown: 'markdown', + plain: 'plain', + sh: 'bash', + shell: 'bash', + svelte: 'markup', + ts: 'typescript', + txt: 'plain', + yaml: 'yaml', + yml: 'yaml' +} + +const extensionLanguages: Record = { + bash: 'bash', + html: 'markup', + json: 'json', + jsonc: 'json', + md: 'markdown', + sh: 'bash', + svelte: 'markup', + ts: 'typescript', + yaml: 'yaml', + yml: 'yaml' +} + +const fileIconClassNames = { + astro: 'material-icon-theme--astro', + console: 'material-icon-theme--console', + css: 'material-icon-theme--css', + docker: 'material-icon-theme--docker', + document: 'material-icon-theme--document', + git: 'material-icon-theme--git', + html: 'material-icon-theme--html', + javascript: 'material-icon-theme--javascript', + json: 'material-icon-theme--json', + lock: 'material-icon-theme--lock', + markdown: 'material-icon-theme--markdown', + mdx: 'material-icon-theme--mdx', + nodejs: 'material-icon-theme--nodejs', + npm: 'material-icon-theme--npm', + react: 'material-icon-theme--react', + reactTs: 'material-icon-theme--react-ts', + settings: 'material-icon-theme--settings', + svelte: 'material-icon-theme--svelte', + tailwindcss: 'material-icon-theme--tailwindcss', + toml: 'material-icon-theme--toml', + typescript: 'material-icon-theme--typescript', + typescriptDef: 'material-icon-theme--typescript-def', + vite: 'material-icon-theme--vite', + wrangler: 'material-icon-theme--wrangler', + xml: 'material-icon-theme--xml', + yaml: 'material-icon-theme--yaml', + svg: 'material-icon-theme--svg' +} as const + +export function resolveLanguage( + language: string | undefined, + displayPath: string | undefined +): string { + const normalizedLanguage = language?.trim().toLowerCase() + if (normalizedLanguage && languageAliases[normalizedLanguage]) { + return languageAliases[normalizedLanguage] + } + + if (displayPath) { + const extension = displayPath.split('.').pop()?.toLowerCase() + if (extension && extensionLanguages[extension]) { + return extensionLanguages[extension] + } + } + + return normalizedLanguage ?? 'plain' +} + +export function resolveLanguageLabel( + language: string | undefined, + displayPath: string | undefined, + resolvedLanguage: string +): string { + if (language?.trim()) { + return language.trim().toLowerCase() + } + + if (displayPath) { + const extension = displayPath.split('.').pop()?.toLowerCase() + if (extension) { + return extension + } + } + + return resolvedLanguage +} + +export function resolveMetaIconClass(pathLike: string | undefined, languageLabel: string): string { + if (pathLike) { + const fileIconClass = resolveFileIconClass(pathLike) + if (fileIconClass !== fileIconClassNames.document) { + return fileIconClass + } + } + + return resolveLanguageIconClass(languageLabel) +} + +export function resolveLanguageIconClass(languageLabel: string): string { + switch (languageLabel.trim().toLowerCase()) { + case 'astro': + return fileIconClassNames.astro + case 'bash': + case 'console': + case 'powershell': + case 'ps1': + case 'shell': + case 'sh': + case 'zsh': + return fileIconClassNames.console + case 'css': + case 'less': + case 'pcss': + case 'postcss': + case 'sass': + case 'scss': + return fileIconClassNames.css + case 'html': + case 'markup': + return fileIconClassNames.html + case 'javascript': + case 'js': + return fileIconClassNames.javascript + case 'json': + case 'jsonc': + return fileIconClassNames.json + case 'markdown': + case 'md': + return fileIconClassNames.markdown + case 'mdsvex': + case 'mdx': + return fileIconClassNames.mdx + case 'react': + case 'jsx': + return fileIconClassNames.react + case 'react-ts': + case 'tsx': + return fileIconClassNames.reactTs + case 'svelte': + return fileIconClassNames.svelte + case 'toml': + return fileIconClassNames.toml + case 'ts': + case 'typescript': + return fileIconClassNames.typescript + case 'xml': + return fileIconClassNames.xml + case 'yaml': + case 'yml': + return fileIconClassNames.yaml + default: + return fileIconClassNames.document + } +} + +export function resolveFileIconClass(pathLike: string): string { + const normalizedPath = normalizePath(pathLike).toLowerCase() + const fileName = basename(normalizedPath) + + if (/\.d\.(ts|mts|cts)$/.test(normalizedPath)) { + return fileIconClassNames.typescriptDef + } + + if (fileName === 'package.json') { + return fileIconClassNames.nodejs + } + + if ( + fileName === 'pnpm-lock.yaml' || + fileName === 'package-lock.json' || + fileName === 'yarn.lock' || + fileName === 'bun.lock' || + fileName === 'bun.lockb' + ) { + return fileIconClassNames.lock + } + + if (fileName === 'dockerfile') { + return fileIconClassNames.docker + } + + if (fileName === '.gitignore' || fileName === '.gitattributes') { + return fileIconClassNames.git + } + + if (fileName.startsWith('.env')) { + return fileIconClassNames.settings + } + + if (/^vite\.config\./.test(fileName)) { + return fileIconClassNames.vite + } + + if (/^wrangler\./.test(fileName)) { + return fileIconClassNames.wrangler + } + + if (/^tailwind\.config\./.test(fileName)) { + return fileIconClassNames.tailwindcss + } + + if (/^postcss\.config\./.test(fileName)) { + return fileIconClassNames.css + } + + if (/^components?\.json$/.test(fileName)) { + return fileIconClassNames.json + } + + if (normalizedPath.endsWith('.tsx')) { + return fileIconClassNames.reactTs + } + + if (normalizedPath.endsWith('.jsx')) { + return fileIconClassNames.react + } + + if ( + normalizedPath.endsWith('.ts') || + normalizedPath.endsWith('.mts') || + normalizedPath.endsWith('.cts') + ) { + return fileIconClassNames.typescript + } + + if ( + normalizedPath.endsWith('.js') || + normalizedPath.endsWith('.mjs') || + normalizedPath.endsWith('.cjs') + ) { + return fileIconClassNames.javascript + } + + if (normalizedPath.endsWith('.svelte')) { + return fileIconClassNames.svelte + } + + if (normalizedPath.endsWith('.astro')) { + return fileIconClassNames.astro + } + + if (normalizedPath.endsWith('.json') || normalizedPath.endsWith('.jsonc')) { + return fileIconClassNames.json + } + + if (normalizedPath.endsWith('.yaml') || normalizedPath.endsWith('.yml')) { + return fileIconClassNames.yaml + } + + if (normalizedPath.endsWith('.md')) { + return fileIconClassNames.markdown + } + + if (normalizedPath.endsWith('.mdx') || normalizedPath.endsWith('.mdsvex')) { + return fileIconClassNames.mdx + } + + if (normalizedPath.endsWith('.html')) { + return fileIconClassNames.html + } + + if ( + normalizedPath.endsWith('.css') || + normalizedPath.endsWith('.scss') || + normalizedPath.endsWith('.sass') || + normalizedPath.endsWith('.less') || + normalizedPath.endsWith('.pcss') + ) { + return fileIconClassNames.css + } + + if (normalizedPath.endsWith('.toml')) { + return fileIconClassNames.toml + } + + if (normalizedPath.endsWith('.xml')) { + return fileIconClassNames.xml + } + + if (normalizedPath.endsWith('.svg')) { + return fileIconClassNames.svg + } + + if ( + normalizedPath.endsWith('.sh') || + normalizedPath.endsWith('.bash') || + normalizedPath.endsWith('.zsh') || + normalizedPath.endsWith('.ps1') + ) { + return fileIconClassNames.console + } + + return fileIconClassNames.document +} diff --git a/apps/documentation/src/lib/components/code/block/lines.ts b/apps/documentation/src/lib/components/code/block/lines.ts new file mode 100644 index 0000000..b46beb1 --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/lines.ts @@ -0,0 +1,86 @@ +import type { DocCodeLineRange } from '$lib/docs/types' +import type { LineState } from './types' + +export function extendFocusLines( + code: string, + ranges: DocCodeLineRange[] | undefined +): DocCodeLineRange[] | undefined { + if (!ranges?.length) { + return undefined + } + + const codeLines = code.split('\n') + + return ranges.map((range) => { + if (!Array.isArray(range)) { + return range + } + + let [start, end] = range + + while (end < codeLines.length && isTrailingClosureLine(codeLines[end])) { + end += 1 + } + + return [start, end] + }) +} + +export function getLineState( + lineIndex: number, + focusLines: DocCodeLineRange[] | undefined, + dimLines: DocCodeLineRange[] | undefined +): LineState { + if (isLineInRanges(lineIndex, focusLines)) { + return 'focus' + } + + if (focusLines?.length) { + return 'dim' + } + + if (isLineInRanges(lineIndex, dimLines)) { + return 'dim' + } + + return 'normal' +} + +export function getFirstLine(ranges: DocCodeLineRange[] | undefined): number | undefined { + if (!ranges?.length) { + return undefined + } + + return ranges.reduce((current, range) => { + const value = Array.isArray(range) ? range[0] : range + if (!current) { + return value + } + + return Math.min(current, value) + }, undefined) +} + +function isLineInRanges(lineIndex: number, ranges: DocCodeLineRange[] | undefined): boolean { + if (!ranges?.length) { + return false + } + + return ranges.some((range) => { + if (Array.isArray(range)) { + return lineIndex >= range[0] && lineIndex <= range[1] + } + + return lineIndex === range + }) +} + +function isTrailingClosureLine(line: string): boolean { + const trimmed = line.trim() + + if (!trimmed) { + return false + } + + return /^[\]\)\}]+[,;\]\)\}]*$/.test(trimmed) || /^<\/[a-z][\w:-]*>$/.test(trimmed) +} diff --git a/apps/documentation/src/lib/components/code/block/path.ts b/apps/documentation/src/lib/components/code/block/path.ts new file mode 100644 index 0000000..1ac180a --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/path.ts @@ -0,0 +1,16 @@ +export function normalizePath(path: string): string { + return path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '') +} + +export function basename(path: string): string { + const normalizedPath = normalizePath(path) + const segments = normalizedPath.split('/').filter(Boolean) + return segments.at(-1) ?? normalizedPath +} + +export function toKebabCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .toLowerCase() +} diff --git a/apps/documentation/src/lib/components/code/block/structure.ts b/apps/documentation/src/lib/components/code/block/structure.ts new file mode 100644 index 0000000..111b203 --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/structure.ts @@ -0,0 +1,218 @@ +import type { DocCodeSnippet, DocCodeTreeEntry } from '$lib/docs/types' +import { + createStructureEntryFromPattern, + inferConfigContextEntries, + isDevflareConfigPath, + isTypeAwarePath +} from './config' +import { resolveFileIconClass } from './language' +import { basename, normalizePath } from './path' +import type { NormalizedCodeFile, TreeNodeState } from './types' + +export function resolveInitialActiveFile( + requestedPath: string | undefined, + files: NormalizedCodeFile[] +): string { + if (requestedPath) { + const normalizedRequestedPath = normalizePath(requestedPath) + const match = files.find((file) => file.path === normalizedRequestedPath) + if (match) { + return match.path + } + } + + return files[0]?.path ?? '__snippet__0' +} + +export function resolveStructureEntries( + snippet: DocCodeSnippet, + files: NormalizedCodeFile[] +): DocCodeTreeEntry[] | undefined { + if (snippet.structure?.length) { + return snippet.structure + } + + return buildImplicitStructureEntries(files) +} + +function buildImplicitStructureEntries(files: NormalizedCodeFile[]): DocCodeTreeEntry[] { + const filesWithPaths = files.filter( + (file): file is NormalizedCodeFile & { displayPath: string } => { + return Boolean(file.displayPath) + } + ) + + if (filesWithPaths.length === 0) { + return [] + } + + const entries: DocCodeTreeEntry[] = [] + const seenPaths = new Set() + const actualPaths = new Set(filesWithPaths.map((file) => file.path)) + const configFile = filesWithPaths.find((file) => isDevflareConfigPath(file.path)) + const hasSourceFile = filesWithPaths.some((file) => file.path.startsWith('src/')) + const hasTestFile = filesWithPaths.some((file) => file.path.startsWith('tests/')) + const hasEnvFile = actualPaths.has('env.d.ts') + const hasProjectContext = Boolean(configFile) || hasSourceFile || hasTestFile || hasEnvFile + const shouldShowEnvFile = + hasProjectContext && filesWithPaths.some((file) => isTypeAwarePath(file.path)) + + function addEntry(entry: DocCodeTreeEntry | undefined): void { + if (!entry) { + return + } + + const path = normalizePath(entry.path) + if (!path || seenPaths.has(path)) { + return + } + + seenPaths.add(path) + entries.push({ + ...entry, + path + }) + } + + if (configFile) { + addEntry({ path: configFile.path }) + } else if (hasProjectContext) { + addEntry({ path: 'devflare.config.ts', muted: true }) + } + + if (configFile) { + for (const entry of inferConfigContextEntries(configFile.code)) { + if (!actualPaths.has(entry.path)) { + addEntry({ + ...entry, + muted: true + }) + } + } + } + + if (hasTestFile && !hasSourceFile) { + addEntry({ path: 'src/fetch.ts', muted: true }) + } + + for (const file of filesWithPaths) { + if (isDevflareConfigPath(file.path) || file.path === 'env.d.ts') { + continue + } + + addEntry({ path: file.path }) + } + + if (hasEnvFile) { + addEntry({ path: 'env.d.ts' }) + } else if (shouldShowEnvFile) { + addEntry({ path: 'env.d.ts', muted: true }) + } + + return entries +} + +export function normalizeStructure( + entries: DocCodeTreeEntry[] | undefined, + files: NormalizedCodeFile[], + activeFile: string +): TreeNodeState[] { + if (!entries?.length) { + return [] + } + + const nodes = new Map() + const order: string[] = [] + const filePaths = new Set(files.map((file) => file.path)) + const pathsWithChildren = new Set() + const referencedPaths = [ + ...entries.map((entry) => normalizePath(entry.path)), + ...files.map((file) => file.displayPath).filter((path): path is string => Boolean(path)) + ] + + for (const referencedPath of referencedPaths) { + const segments = referencedPath.split('/').filter(Boolean) + let currentPath = '' + + for (const segment of segments.slice(0, -1)) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment + pathsWithChildren.add(currentPath) + } + } + + const ensureNode = ( + path: string, + kind: 'file' | 'folder', + muted: boolean, + available: boolean + ) => { + const normalizedPath = normalizePath(path) + const existing = nodes.get(normalizedPath) + const nextNode: TreeNodeState = { + path: normalizedPath, + kind, + muted, + available, + active: kind === 'file' && normalizedPath === activeFile, + depth: normalizedPath.split('/').filter(Boolean).length - 1, + name: basename(normalizedPath), + iconClass: kind === 'file' ? resolveFileIconClass(normalizedPath) : undefined + } + + if (existing) { + nodes.set(normalizedPath, { + ...existing, + kind, + muted: existing.muted && muted, + available: existing.available || available, + active: existing.active || nextNode.active, + iconClass: kind === 'file' ? (nextNode.iconClass ?? existing.iconClass) : existing.iconClass + }) + return + } + + order.push(normalizedPath) + nodes.set(normalizedPath, nextNode) + } + + for (const entry of entries) { + const normalizedPath = normalizePath(entry.path) + const segments = normalizedPath.split('/').filter(Boolean) + let currentPath = '' + + for (const [index, segment] of segments.entries()) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment + const isLastSegment = index === segments.length - 1 + const kind = isLastSegment + ? (entry.kind ?? (pathsWithChildren.has(currentPath) ? 'folder' : 'file')) + : 'folder' + ensureNode( + currentPath, + kind, + isLastSegment ? Boolean(entry.muted) : false, + filePaths.has(currentPath) + ) + } + } + + for (const file of files) { + if (!file.displayPath) { + continue + } + + const segments = file.path.split('/').filter(Boolean) + let currentPath = '' + + for (const [index, segment] of segments.entries()) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment + ensureNode( + currentPath, + index === segments.length - 1 ? 'file' : 'folder', + false, + filePaths.has(currentPath) + ) + } + } + + return order.map((path) => nodes.get(path)!).filter(Boolean) +} diff --git a/apps/documentation/src/lib/components/code/block/types.ts b/apps/documentation/src/lib/components/code/block/types.ts new file mode 100644 index 0000000..339d0cc --- /dev/null +++ b/apps/documentation/src/lib/components/code/block/types.ts @@ -0,0 +1,43 @@ +export type LineState = 'normal' | 'focus' | 'dim' + +export interface TreeNodeState { + kind: 'file' | 'folder' + muted: boolean + available: boolean + active: boolean + depth: number + name: string + path: string + iconClass?: string +} + +export interface NormalizedCodeLine { + index: number + number: number + html: string + text: string + state: LineState +} + +export interface NormalizedCodeFile { + path: string + displayPath?: string + label: string + iconClass: string + metaIconClass: string + language: string + languageLabel: string + code: string + copyCode: string + lines: NormalizedCodeLine[] + firstFocusLine?: number +} + +export interface NormalizedCodeSnippet { + title: string + description?: string + files: NormalizedCodeFile[] + structure: TreeNodeState[] + activeFile: string + hasStructure: boolean +} diff --git a/apps/documentation/src/lib/components/content/InlineText.svelte b/apps/documentation/src/lib/components/content/InlineText.svelte new file mode 100644 index 0000000..b2c7b04 --- /dev/null +++ b/apps/documentation/src/lib/components/content/InlineText.svelte @@ -0,0 +1,39 @@ + + +{#each segments as segment} + {#if segment.kind === 'code'} + {segment.value} + {:else if segment.kind === 'link'} + + {segment.value} + + (opens in a new tab) + + {:else} + {segment.value} + {/if} +{/each} diff --git a/apps/documentation/src/lib/components/content/SectionHeading.svelte b/apps/documentation/src/lib/components/content/SectionHeading.svelte new file mode 100644 index 0000000..d09d88e --- /dev/null +++ b/apps/documentation/src/lib/components/content/SectionHeading.svelte @@ -0,0 +1,68 @@ + + +
+ {#if eyebrow} +

+ {/if} +
+ + {#if label} + {#if labelTooltip} + + {:else} + + + + {/if} + {/if} +
+ {#if description} +

+ {/if} + {@render children?.()} +
diff --git a/apps/documentation/src/lib/components/content/inline.ts b/apps/documentation/src/lib/components/content/inline.ts new file mode 100644 index 0000000..977bad8 --- /dev/null +++ b/apps/documentation/src/lib/components/content/inline.ts @@ -0,0 +1,119 @@ +export type InlineTextSegment = + | { + kind: 'text' | 'code' + value: string + } + | { + kind: 'link' + value: string + href: string + } + +function appendTextSegment(segments: InlineTextSegment[], value: string): void { + if (!value) { + return + } + + const previousSegment = segments.at(-1) + if (previousSegment?.kind === 'text') { + previousSegment.value += value + return + } + + segments.push({ + kind: 'text', + value + }) +} + +function appendCodeSegment(segments: InlineTextSegment[], value: string): void { + if (!value) { + appendTextSegment(segments, '``') + return + } + + segments.push({ + kind: 'code', + value + }) +} + +function appendLinkSegment(segments: InlineTextSegment[], value: string, href: string): void { + segments.push({ + kind: 'link', + value, + href + }) +} + +function readLink( + value: string, + start: number +): { label: string; href: string; end: number } | undefined { + const labelEnd = value.indexOf(']', start + 1) + if (labelEnd === -1 || value[labelEnd + 1] !== '(') { + return undefined + } + + const hrefEnd = value.indexOf(')', labelEnd + 2) + if (hrefEnd === -1) { + return undefined + } + + const label = value.slice(start + 1, labelEnd) + const href = value.slice(labelEnd + 2, hrefEnd) + if (!label || !href) { + return undefined + } + + return { label, href, end: hrefEnd + 1 } +} + +export function parseInlineText(value: string): InlineTextSegment[] { + const segments: InlineTextSegment[] = [] + let currentSegment = '' + let inCode = false + let index = 0 + + while (index < value.length) { + const character = value[index] + + if (!inCode && character === '[') { + const link = readLink(value, index) + if (link !== undefined) { + appendTextSegment(segments, currentSegment) + appendLinkSegment(segments, link.label, link.href) + currentSegment = '' + index = link.end + continue + } + } + + if (character !== '`') { + currentSegment += character + index += 1 + continue + } + + if (inCode) { + appendCodeSegment(segments, currentSegment) + currentSegment = '' + inCode = false + index += 1 + continue + } + + appendTextSegment(segments, currentSegment) + currentSegment = '' + inCode = true + index += 1 + } + + if (inCode) { + appendTextSegment(segments, `\`${currentSegment}`) + return segments + } + + appendTextSegment(segments, currentSegment) + return segments +} diff --git a/apps/documentation/src/lib/components/home/HeroCta.svelte b/apps/documentation/src/lib/components/home/HeroCta.svelte new file mode 100644 index 0000000..466f891 --- /dev/null +++ b/apps/documentation/src/lib/components/home/HeroCta.svelte @@ -0,0 +1,65 @@ + + + +
+
+
+

+
+ {#if meta} +

+ {/if} +
+ +

+ +

+

+ +

+ + {#if highlights.length > 0} +
+ {#each highlights as highlight} + + + + + {/each} +
+ {/if} + +
+ + {m.cta_open_guide()} + + + โ†’ + +
+
+
diff --git a/apps/documentation/src/lib/components/home/HomeNext.svelte b/apps/documentation/src/lib/components/home/HomeNext.svelte new file mode 100644 index 0000000..57f8051 --- /dev/null +++ b/apps/documentation/src/lib/components/home/HomeNext.svelte @@ -0,0 +1,98 @@ + + +
+ + +
+
+ + + + + + + + + + {#each nextPaths as path} + + + + + + {/each} + +
I need to...Open firstThen open
+ + {path.first.label} + + + + {path.followUp.label} + +
+
+
+ + +
diff --git a/apps/documentation/src/lib/components/home/MiniSnippet.svelte b/apps/documentation/src/lib/components/home/MiniSnippet.svelte new file mode 100644 index 0000000..08db339 --- /dev/null +++ b/apps/documentation/src/lib/components/home/MiniSnippet.svelte @@ -0,0 +1,48 @@ + + +
+ {#if label} +
+

+
+ {/if} + + {#if title} +

+ {/if} + +
+ {#each lines as line, index} +
+ {index + 1} + {line || ' '} +
+ {/each} +
+
diff --git a/apps/documentation/src/lib/components/home/PlatformFlow.svelte b/apps/documentation/src/lib/components/home/PlatformFlow.svelte new file mode 100644 index 0000000..5bc36ee --- /dev/null +++ b/apps/documentation/src/lib/components/home/PlatformFlow.svelte @@ -0,0 +1,69 @@ + + + +
+
+
+ + + + +

+ +

+
+ +
+ {#each outcomeCards as outcome} +
+

+

+

+
+ {/each} +
+
+
+
diff --git a/apps/documentation/src/lib/components/layout/Surface.svelte b/apps/documentation/src/lib/components/layout/Surface.svelte new file mode 100644 index 0000000..75a14c8 --- /dev/null +++ b/apps/documentation/src/lib/components/layout/Surface.svelte @@ -0,0 +1,50 @@ + + + + {@render children?.()} + diff --git a/apps/documentation/src/lib/components/layout/Tooltip.svelte b/apps/documentation/src/lib/components/layout/Tooltip.svelte new file mode 100644 index 0000000..606dda2 --- /dev/null +++ b/apps/documentation/src/lib/components/layout/Tooltip.svelte @@ -0,0 +1,27 @@ + + + + +{#if tooltip.visible && tooltip.content !== undefined} +
+ +
+{/if} \ No newline at end of file diff --git a/apps/documentation/src/lib/components/navigation/PillLink.svelte b/apps/documentation/src/lib/components/navigation/PillLink.svelte new file mode 100644 index 0000000..5bb57b3 --- /dev/null +++ b/apps/documentation/src/lib/components/navigation/PillLink.svelte @@ -0,0 +1,47 @@ + + + + {#if children} + {@render children()} + {:else} + + {/if} + diff --git a/apps/documentation/src/lib/components/navigation/Sidebar.svelte b/apps/documentation/src/lib/components/navigation/Sidebar.svelte new file mode 100644 index 0000000..83d1c43 --- /dev/null +++ b/apps/documentation/src/lib/components/navigation/Sidebar.svelte @@ -0,0 +1,92 @@ + + + diff --git a/apps/documentation/src/lib/docs/content.ts b/apps/documentation/src/lib/docs/content.ts new file mode 100644 index 0000000..9724a69 --- /dev/null +++ b/apps/documentation/src/lib/docs/content.ts @@ -0,0 +1,277 @@ +import { bindingDocCategories, bindingDocs } from './content/bindings' +import { buildAppsDocs } from './content/build-apps' +import { configurationDocs } from './content/configuration' +import { devflareDocs } from './content/devflare' +import { examplesDocs } from './content/examples' +import { frameworkDocs } from './content/frameworks' +import { operationsDocs } from './content/operations' +import { shipOperateDocs } from './content/ship-operate' +import { startHereDocs } from './content/start-here' +import type { DocCategory, DocGroup, DocPage } from './types' + +export function docPath(slug: string): string { + return `/docs/${slug}` +} + +const allDocs: DocPage[] = [ + ...startHereDocs, + ...buildAppsDocs, + ...configurationDocs, + ...devflareDocs, + ...examplesDocs, + ...frameworkDocs, + ...bindingDocs, + ...operationsDocs, + ...shipOperateDocs +] + +export const docsBySlug = new Map(allDocs.map((doc) => [doc.slug, doc])) + +const docsByAlias = new Map( + allDocs.flatMap((doc) => (doc.aliases ?? []).map((alias) => [alias, doc] as const)) +) + +export function getDoc(slug: string): DocPage | undefined { + return docsBySlug.get(slug) ?? docsByAlias.get(slug) +} + +export function getCanonicalDocSlug(slug: string): string | undefined { + return getDoc(slug)?.slug +} + +export function getAdjacentDocs(slug: string): { previous?: DocPage; next?: DocPage } { + const index = docs.findIndex((doc) => doc.slug === slug) + if (index === -1) { + return {} + } + + return { + previous: docs[index - 1], + next: docs[index + 1] + } +} + +interface DocCategoryDefinition { + id: string + title: string + description: string + sidebarDisplay?: 'disclosure' | 'links' | 'standalone' + slugs: string[] + sidebarSlugs?: string[] +} + +interface DocGroupDefinition { + title: string + description: string + categories: DocCategoryDefinition[] +} + +function pickDocs(slugs: string[]): DocPage[] { + return slugs.map((slug) => docsBySlug.get(slug)).filter((doc): doc is DocPage => Boolean(doc)) +} + +const docStructure: DocGroupDefinition[] = [ + { + title: 'Quickstart', + description: + 'See why Devflare exists, build the smallest safe first worker, and move into routes, bindings, previews, and tests when the app needs them.', + categories: [ + { + id: 'foundations', + title: 'Foundations', + description: + 'Start with the mental model, the smallest safe worker, and one real test before you branch into app-specific setup.', + sidebarDisplay: 'links', + slugs: [ + 'what-devflare-is', + 'first-worker', + 'first-unit-test', + 'first-route-tree', + 'first-bindings', + 'deploy-and-preview' + ] + } + ] + }, + { + title: 'Devflare', + description: + 'Keep the day-to-day Devflare surfaces easy to scan: runtime model, HTTP split, authored config rules, CLI workflow, helpers, testing, and framework lanes all live here instead of being scattered across deploy-only docs.', + categories: [ + { + id: 'cli', + title: 'CLI', + description: + 'Use the everyday command loop, keep deploy intent explicit, and let package-local commands resolve the config you actually mean to act on.', + sidebarDisplay: 'standalone', + slugs: ['devflare-cli'] + }, + { + id: 'project-architecture', + title: 'Project Architecture', + description: + 'See how real Devflare packages are laid out on disk and which files you normally edit.', + sidebarDisplay: 'standalone', + slugs: ['project-architecture', 'bridge-architecture-internals'] + }, + { + id: 'routing', + title: 'Routing', + description: + 'Keep request-wide middleware separate from route leaves so HTTP stays readable as the app grows.', + sidebarDisplay: 'standalone', + slugs: ['http-routing'] + }, + { + id: 'configuration', + title: 'Configuration', + description: + 'Keep authored config readable, stable, and clearly separated from generated output.', + slugs: [ + 'config-basics', + 'full-config', + 'project-shape', + 'worker-surfaces', + 'generated-types', + 'config-environments', + 'typed-env-vars', + 'config-previews', + 'runtime-deploy-settings' + ] + }, + { + id: 'runtime', + title: 'Runtime', + description: + 'Use runtime helpers, request-wide middleware, transport hooks, and other worker-wide surfaces without turning every page into an internals guide.', + slugs: [ + 'runtime-context', + 'runtime-context-internals', + 'sequence-middleware', + 'runtime-handler-styles', + 'transport-file' + ] + }, + { + id: 'testing', + title: 'Testing', + description: + 'Start with why the testing experience feels different, use the testing map and built-in harness for runtime-shaped checks, and jump to binding-specific guides when the test story changes by binding.', + slugs: [ + 'why-testing-feels-native', + 'testing-overview', + 'create-test-context', + 'binding-testing-guides', + 'test-helper-reference' + ] + }, + { + id: 'frameworks', + title: 'Frameworks', + description: + 'Choose the right host lane for worker-rendered Svelte, standalone Vite apps, and full SvelteKit shells without losing the worker-first mental model.', + slugs: ['svelte-with-rolldown', 'vite-standalone', 'sveltekit-with-devflare'] + } + ] + }, + { + title: 'Ship & operate', + description: + 'Deploy explicitly, choose the right preview model, manage preview lifecycle cleanly, and keep CI/CD plus verification honest.', + categories: [ + { + id: 'ci-cd', + title: 'CI/CD', + description: + 'Use small GitHub workflows that keep triggers, permissions, impact checks, deploy intent, and feedback easy to review.', + slugs: ['github-workflows'] + }, + { + id: 'deploy-targets', + title: 'Deploy targets', + description: + 'Move from local build output to production or preview deploys without guessing which destination you are about to hit.', + slugs: [ + 'deploy-command-recipes', + 'production-deploys', + 'monorepo-turborepo', + 'preview-strategies' + ] + }, + { + id: 'operations', + title: 'Operations', + description: + 'Choose account context, inspect live production, manage Worker names and tokens, gate paid remote tests deliberately, and reuse the public Cloudflare helper API when automation needs the same rules.', + slugs: ['control-plane-operations', 'cloudflare-api'] + }, + { + id: 'preview-lifecycle', + title: 'Preview lifecycle', + description: + 'Inspect and clean up preview scopes after they exist so preview infrastructure does not sprawl.', + slugs: ['preview-operations'] + }, + { + id: 'verification', + title: 'Verification', + description: + 'Use runtime-shaped tests and keep automation observable enough to trust during releases.', + slugs: ['testing-and-automation', 'docs-release-gates'] + } + ] + }, + { + title: 'Guides', + description: + 'Use cross-cutting guides to choose the right storage, state, async, file-delivery, and worker-composition patterns before you dive into one binding reference page.', + categories: [ + { + id: 'guides', + title: 'Guides', + description: + 'Choose the right architecture and product boundary first, then let the specific binding pages own the exact authoring and runtime mechanics.', + sidebarDisplay: 'links', + slugs: [ + 'feature-index', + 'storage-bindings', + 'r2-uploads-and-delivery', + 'durable-objects-and-queues', + 'multi-workers' + ] + } + ] + }, + { + title: 'Bindings', + description: + 'Use the per-binding guides for the exact authoring, runtime, testing, preview, and example details once the guide pages have already helped you choose the right pattern.', + categories: [...bindingDocCategories] + } +] + +export const docGroups: DocGroup[] = docStructure.map((group) => { + const categories: DocCategory[] = group.categories.map((category) => ({ + id: category.id, + title: category.title, + description: category.description, + sidebarDisplay: category.sidebarDisplay, + items: pickDocs(category.slugs), + sidebarItems: pickDocs(category.sidebarSlugs ?? category.slugs) + })) + + return { + title: group.title, + description: group.description, + categories, + items: categories.flatMap((category) => category.items) + } +}) + +export const docs: DocPage[] = Array.from( + new Map( + docGroups + .flatMap((group) => group.categories.flatMap((category) => category.items)) + .map((doc) => [doc.slug, doc]) + ).values() +) diff --git a/apps/documentation/src/lib/docs/content/bindings.ts b/apps/documentation/src/lib/docs/content/bindings.ts new file mode 100644 index 0000000..0d90db0 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings.ts @@ -0,0 +1,2 @@ +export { bindingDocCategories, bindingDocs, bindingTestingGuides } from './bindings/index' +export type { BindingTestingGuideLink } from './bindings/index' diff --git a/apps/documentation/src/lib/docs/content/bindings/cloudflare-reference.ts b/apps/documentation/src/lib/docs/content/bindings/cloudflare-reference.ts new file mode 100644 index 0000000..fb89f2e --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/cloudflare-reference.ts @@ -0,0 +1,348 @@ +import type { DocHeaderCloudflareDocs } from '../../types' +import type { BindingGuideDefinition } from './shared' + +export function getCloudflareBindingReference(guide: BindingGuideDefinition): { + title: string + href: string + description: string + citation: string +} { + switch (guide.slugBase) { + case 'kv': + return { + title: 'Cloudflare Workers KV docs', + href: 'https://developers.cloudflare.com/kv/', + description: + 'Platform reference for KV namespaces, binding APIs, limits, and Wrangler-facing setup.', + citation: 'Cloudflare Docs' + } + + case 'd1': + return { + title: 'Cloudflare D1 docs', + href: 'https://developers.cloudflare.com/d1/', + description: + 'Platform reference for D1 databases, Worker APIs, migrations, and database limits.', + citation: 'Cloudflare Docs' + } + + case 'r2': + return { + title: 'Cloudflare R2 docs', + href: 'https://developers.cloudflare.com/r2/', + description: + 'Platform reference for buckets, object APIs, public-versus-private delivery, and account features.', + citation: 'Cloudflare Docs' + } + + case 'durable-object': + return { + title: 'Cloudflare Durable Objects docs', + href: 'https://developers.cloudflare.com/durable-objects/', + description: + 'Platform reference for object identity, storage, alarms, migrations, and deployment caveats.', + citation: 'Cloudflare Docs' + } + + case 'queue': + return { + title: 'Cloudflare Queues docs', + href: 'https://developers.cloudflare.com/queues/', + description: + 'Platform reference for queue producers, consumers, delivery guarantees, retries, batching, and DLQs.', + citation: 'Cloudflare Docs' + } + + case 'service': + return { + title: 'Cloudflare Service bindings docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/', + description: + 'Platform reference for worker-to-worker bindings, service entrypoints, and the underlying runtime contract.', + citation: 'Cloudflare Docs' + } + + case 'ai': + return { + title: 'Cloudflare Workers AI docs', + href: 'https://developers.cloudflare.com/workers-ai/configuration/bindings/', + description: + 'Platform reference for model access, remote inference behavior, pricing, and account prerequisites.', + citation: 'Cloudflare Docs' + } + + case 'vectorize': + return { + title: 'Cloudflare Vectorize docs', + href: 'https://developers.cloudflare.com/vectorize/', + description: + 'Platform reference for indexes, embeddings, remote querying, and preview-aware index lifecycle.', + citation: 'Cloudflare Docs' + } + + case 'hyperdrive': + return { + title: 'Cloudflare Hyperdrive docs', + href: 'https://developers.cloudflare.com/hyperdrive/', + description: + 'Platform reference for database acceleration, connection strings, limits, and supported databases.', + citation: 'Cloudflare Docs' + } + + case 'browser': + return { + title: 'Cloudflare Browser Rendering docs', + href: 'https://developers.cloudflare.com/browser-rendering/workers-bindings/', + description: + 'Platform reference for browser sessions, quick actions, automation limits, and integration methods.', + citation: 'Cloudflare Docs' + } + + case 'analytics-engine': + return { + title: 'Cloudflare Workers Analytics Engine docs', + href: 'https://developers.cloudflare.com/analytics/analytics-engine/', + description: + 'Platform reference for write APIs, SQL querying, analytics ingestion patterns, and product limits.', + citation: 'Cloudflare Docs' + } + + case 'send-email': + return { + title: 'Cloudflare send_email binding docs', + href: 'https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/', + description: + 'Platform reference for send_email binding restrictions, verified destinations, and Email Workers setup.', + citation: 'Cloudflare Docs' + } + + case 'rate-limiting': + return { + title: 'Cloudflare Rate Limiting docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/', + description: + 'Platform reference for rate limiting binding configuration, `limit()` calls, locality, and limits.', + citation: 'Cloudflare Docs' + } + + case 'version-metadata': + return { + title: 'Cloudflare Version Metadata docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/', + description: + 'Platform reference for Worker version id, version tag, and version timestamp bindings.', + citation: 'Cloudflare Docs' + } + + case 'worker-loaders': + return { + title: 'Cloudflare Dynamic Worker Loaders docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/', + description: + 'Platform reference for loading dynamic Workers and arbitrary Worker code at runtime.', + citation: 'Cloudflare Docs' + } + + case 'secrets-store': + return { + title: 'Cloudflare Secrets Store docs', + href: 'https://developers.cloudflare.com/workers/configuration/secrets/', + description: + 'Platform reference for secrets, account-level Secrets Store bindings, and secure Worker access.', + citation: 'Cloudflare Docs' + } + + case 'ai-search': + return { + title: 'Cloudflare AI Search docs', + href: 'https://developers.cloudflare.com/ai-search/api/search/workers-binding/', + description: + 'Platform reference for AI Search instance and namespace bindings from Workers.', + citation: 'Cloudflare Docs' + } + + case 'mtls-certificates': + return { + title: 'Cloudflare mTLS docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/mtls/', + description: + 'Platform reference for mTLS certificate bindings and certificate-backed outbound fetches.', + citation: 'Cloudflare Docs' + } + + case 'dispatch-namespaces': + return { + title: 'Cloudflare Workers for Platforms docs', + href: 'https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/', + description: + 'Platform reference for dispatch namespaces, dynamic dispatch Workers, and tenant Worker routing.', + citation: 'Cloudflare Docs' + } + + case 'workflows': + return { + title: 'Cloudflare Workflows docs', + href: 'https://developers.cloudflare.com/workflows/build/trigger-workflows/', + description: + 'Platform reference for creating Workflow bindings and triggering Workflow instances from Workers.', + citation: 'Cloudflare Docs' + } + + case 'pipelines': + return { + title: 'Cloudflare Pipelines docs', + href: 'https://developers.cloudflare.com/pipelines/build-with-pipelines/sources/workers-apis/', + description: + 'Platform reference for sending records from Workers into Cloudflare Pipelines.', + citation: 'Cloudflare Docs' + } + + case 'images': + return { + title: 'Cloudflare Images docs', + href: 'https://developers.cloudflare.com/images/transform-images/bindings/', + description: + 'Platform reference for Images bindings, transformations, billing, and Workers API setup.', + citation: 'Cloudflare Docs' + } + + case 'media-transformations': + return { + title: 'Cloudflare Media Transformations docs', + href: 'https://developers.cloudflare.com/stream/transform-videos/bindings/', + description: + 'Platform reference for Media Transformations bindings, beta limits, and Workers API setup.', + citation: 'Cloudflare Docs' + } + + case 'artifacts': + return { + title: 'Cloudflare Artifacts docs', + href: 'https://developers.cloudflare.com/artifacts/api/workers-binding/', + description: + 'Platform reference for Artifacts Workers bindings, repos, tokens, and namespace methods.', + citation: 'Cloudflare Docs' + } + + case 'containers': + return { + title: 'Cloudflare Containers docs', + href: 'https://developers.cloudflare.com/containers/container-class/', + description: + 'Platform reference for the Container class, container instances, and Worker interaction helpers.', + citation: 'Cloudflare Docs' + } + + default: + return { + title: 'Cloudflare Workers bindings docs', + href: 'https://developers.cloudflare.com/workers/runtime-apis/bindings/', + description: + 'Platform reference for the underlying binding contract on Cloudflare Workers.', + citation: 'Cloudflare Docs' + } + } +} + +export function getCloudflareBindingIntro(guide: BindingGuideDefinition): string { + switch (guide.slugBase) { + case 'kv': + return 'Workers KV is a global key-value store for low-latency reads and lightweight shared data.' + + case 'd1': + return 'D1 is Cloudflareโ€™s serverless SQL database for applications that run on Workers.' + + case 'r2': + return 'R2 is Cloudflare object storage for files, uploads, generated assets, and private objects.' + + case 'durable-object': + return 'Durable Objects give Workers a named place for stateful coordination, storage, and alarms.' + + case 'queue': + return 'Queues let Workers send messages to background consumers with retries, batching, and dead-letter handling.' + + case 'service': + return 'Service bindings let one Worker call another Worker without routing through a public URL.' + + case 'ai': + return 'Workers AI lets Workers run Cloudflare-hosted machine-learning models through an env binding.' + + case 'vectorize': + return 'Vectorize stores embeddings in Cloudflare-managed indexes for similarity search from Workers.' + + case 'hyperdrive': + return 'Hyperdrive gives Workers a pooled, Cloudflare-managed connection path to existing PostgreSQL databases.' + + case 'browser': + return 'Browser Rendering lets Workers drive a headless browser for screenshots, PDFs, and page automation.' + + case 'analytics-engine': + return 'Analytics Engine lets Workers write structured data points for later querying and operational analysis.' + + case 'send-email': + return 'The send_email binding lets Workers send outbound email through Cloudflare Email Routing.' + + case 'rate-limiting': + return 'Rate Limiting bindings let Workers enforce fixed-window limits from inside application code.' + + case 'version-metadata': + return 'Version Metadata exposes a Worker version id, version tag, and timestamp to code running in that version.' + + case 'worker-loaders': + return 'Worker Loader bindings let a Worker load additional dynamic Workers at runtime.' + + case 'secrets-store': + return 'Secrets Store bindings let Workers read account-level secrets without storing secret values in code.' + + case 'ai-search': + return 'AI Search bindings let Workers search and chat with indexed content from Cloudflare AI Search instances.' + + case 'mtls-certificates': + return 'mTLS certificate bindings let a Worker make outbound fetches with a client certificate.' + + case 'dispatch-namespaces': + return 'Dispatch namespace bindings let Workers for Platforms route requests to tenant Workers by name.' + + case 'workflows': + return 'Workflows bindings let Workers start and inspect durable multi-step workflow instances.' + + case 'pipelines': + return 'Pipelines bindings let Workers send event records into Cloudflare-managed ingestion pipelines.' + + case 'images': + return 'Images bindings let Workers transform, resize, and encode images without public image URLs.' + + case 'media-transformations': + return 'Media Transformations bindings let Workers transform video or audio from protected sources.' + + case 'artifacts': + return 'Artifacts bindings let Workers create and manage Git-compatible repos and repo tokens.' + + case 'containers': + return 'Containers let a Worker hand requests to stateful code running from a container image.' + + default: + return guide.categoryDescription + } +} + +export function createBindingHeaderCloudflareDocs( + guide: BindingGuideDefinition +): DocHeaderCloudflareDocs { + const reference = getCloudflareBindingReference(guide) + + return { + label: 'Cloudflare Documentation', + title: reference.title, + href: reference.href, + summary: getCloudflareBindingIntro(guide) + } +} + +export function getCloudflareRuntimeComparison(guide: BindingGuideDefinition): string { + if (guide.localStory.toLowerCase().startsWith('remote-oriented')) { + return 'Cloudflareโ€™s docs focus on the real remote product behavior, account requirements, and runtime constraints on the platform.' + } + + return 'Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself.' +} diff --git a/apps/documentation/src/lib/docs/content/bindings/compact-guides-1.ts b/apps/documentation/src/lib/docs/content/bindings/compact-guides-1.ts new file mode 100644 index 0000000..e039558 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/compact-guides-1.ts @@ -0,0 +1,541 @@ +import { type BindingGuideDefinition, createCompactBindingGuide } from './shared' + +export const compactBindingGuidesPart1: BindingGuideDefinition[] = [ + createCompactBindingGuide({ + slugBase: 'rate-limiting', + label: 'Rate Limiting', + categoryDescription: + 'Fixed-window request limits with Miniflare-backed local behavior and a pure mock for unit tests.', + configKey: 'bindings.rateLimits', + authoringShape: 'Record', + localStory: + 'Offline-native: Miniflare and Devflare pure mocks can exercise application-level rate limit behavior', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `ratelimits`', + envType: '`RateLimit`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()`', + testHelper: '`createMockRateLimit()` / `createMockEnv({ rateLimits })`', + bestFor: + 'login throttles, per-user limits, and API guardrails that can use Cloudflare fixed windows', + remoteBoundary: + 'Cloudflare owns account namespace ids and production enforcement, but the local limiter is useful for deterministic app tests.', + configSnippet: { + title: 'Smallest Rate Limiting config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'limited-worker', + bindings: { + rateLimits: { + LOGIN_RATE_LIMIT: { + namespaceId: '1001', + simple: { + limit: 20, + period: 60 + } + } + } + } +})` + }, + usageSnippet: { + title: 'Use the limiter in a request path', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const key = request.headers.get('cf-connecting-ip') ?? 'local' + const outcome = await env.LOGIN_RATE_LIMIT.limit({ key }) + + if (!outcome.success) { + return new Response('slow down', { status: 429 }) + } + + return new Response('ok') +}` + }, + testSnippet: { + title: 'Pure unit test for rate-limit branching', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockEnv } from 'devflare/test' + +test('blocks the second call in the same window', async () => { + const env = createMockEnv({ + rateLimits: { + LOGIN_RATE_LIMIT: { limit: 1, period: 60 } + } + }) + + expect((await env.LOGIN_RATE_LIMIT.limit({ key: 'user-1' })).success).toBe(true) + expect((await env.LOGIN_RATE_LIMIT.limit({ key: 'user-1' })).success).toBe(false) +})` + }, + compileOutput: String.raw`{ + "ratelimits": [ + { "name": "LOGIN_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'version-metadata', + label: 'Version Metadata', + categoryDescription: + 'Version identity for deployed Workers, with deterministic metadata in local tests.', + configKey: 'bindings.versionMetadata', + authoringShape: '{ binding: string }', + localStory: + 'Offline-native: Devflare can provide deterministic local metadata without Cloudflare state', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `version_metadata`', + envType: '`WorkerVersionMetadata`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()`', + testHelper: '`createMockVersionMetadata()` / `createMockEnv({ versionMetadata })`', + bestFor: + 'responses, logs, and diagnostics that need the current Worker version id, tag, or timestamp', + remoteBoundary: + 'Cloudflare supplies real deployment metadata; local tests should assert deterministic fallback behavior only.', + configSnippet: { + title: 'Smallest Version Metadata config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'versioned-worker', + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } +})` + }, + usageSnippet: { + title: 'Return the current version tag', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return Response.json({ + tag: env.CF_VERSION_METADATA.tag, + id: env.CF_VERSION_METADATA.id + }) +}` + }, + testSnippet: { + title: 'Assert deterministic local metadata', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockEnv } from 'devflare/test' + +test('uses deterministic local version metadata', () => { + const env = createMockEnv({ versionMetadata: 'CF_VERSION_METADATA' }) + + expect(env.CF_VERSION_METADATA.tag).toBe('local') +})` + }, + compileOutput: String.raw`{ + "version_metadata": { + "binding": "CF_VERSION_METADATA" + } +}` + }), + createCompactBindingGuide({ + slugBase: 'worker-loaders', + label: 'Worker Loaders', + categoryDescription: + 'Dynamic Worker loader bindings for apps that explicitly supply or mock tenant Worker payloads.', + configKey: 'bindings.workerLoaders', + authoringShape: 'Record', + localStory: + 'Full local support through Miniflare Worker Loader bindings and explicit pure-test Worker stubs', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `worker_loaders`', + envType: '`WorkerLoader`', + defaultHarness: '`createTestContext()` with explicit Worker payloads or a pure stub', + testHelper: '`createMockWorkerLoader()` / `createMockEnv({ workerLoaders })`', + bestFor: 'Dynamic Workers where the app loads Worker code at runtime from an explicit source', + remoteBoundary: + 'Cloudflare owns dynamic Worker upload, discovery, and hosted lifecycle; local code should pass explicit payloads or stubs.', + configSnippet: { + title: 'Smallest Worker Loader config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'loader-worker', + bindings: { + workerLoaders: { + LOADER: {} + } + } +})` + }, + usageSnippet: { + title: 'Load an explicit Worker payload', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const stub = env.LOADER.get('tenant-a', () => ({ + compatibilityDate: '2026-04-26', + mainModule: 'index.js', + modules: { + 'index.js': 'export default { fetch() { return new Response("ok") } }' + } + })) + + return stub.fetch(request) +}` + }, + testSnippet: { + title: 'Pure test with an explicit Worker stub', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockWorkerLoader } from 'devflare/test' + +test('uses a supplied dynamic Worker stub', async () => { + const loader = createMockWorkerLoader({ + stub: { + fetch: async () => new Response('tenant-ok') + } + }) + + const stub = loader.get('tenant-a', () => ({ mainModule: 'index.js', modules: {} })) + expect(await (await stub.fetch('https://example.com')).text()).toBe('tenant-ok') +})` + }, + compileOutput: String.raw`{ + "worker_loaders": [ + { "binding": "LOADER" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'secrets-store', + label: 'Secrets Store', + categoryDescription: + 'Account-level Secrets Store bindings with local read-only values for dev and tests.', + configKey: 'bindings.secretsStore', + authoringShape: 'secretsStoreId + Record', + localStory: + 'Full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values for pure tests', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/secrets/local-secrets.ts', + 'packages/devflare/src/cli/commands/secrets.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `secrets_store_secrets`', + envType: '`SecretsStoreSecret`', + defaultHarness: '`createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })`', + testHelper: '`createMockSecretsStoreSecret()` / `createMockEnv({ secretsStore })`', + bestFor: + 'shared account secrets that should be referenced by store id and secret name instead of copied into config', + remoteBoundary: + 'Cloudflare owns remote account secret provisioning and sync; Devflare reads only project-local secret values unless you deploy or test against Cloudflare.', + configSnippet: { + title: 'Smallest Secrets Store config with one default store', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'secret-worker', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + STRIPE_WEBHOOK_SECRET: 'stripe-webhook-secret' + } + } +})` + }, + usageSnippet: { + title: 'Protect an internal route with a shared API token', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const token = await env.API_TOKEN.get() + const authorization = request.headers.get('authorization') + + if (authorization !== 'Bearer ' + token) { + return new Response('unauthorized', { status: 401 }) + } + + return Response.json({ ok: true }) +}` + }, + testSnippet: { + title: 'Fixture a Secrets Store value offline', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createOfflineEnv } from 'devflare/test' +import config from '../devflare.config' + +test('reads a fixed offline secret', async () => { + const env = createOfflineEnv(config, { + secretsStore: { + API_TOKEN: 'test-token' + } + }) + +expect(await env.API_TOKEN.get()).toBe('test-token') +})` + }, + overviewSections: [ + { + id: 'local-secret-values', + title: 'Set local values without putting secrets in config', + paragraphs: [ + 'Keep `devflare.config.ts` limited to store IDs and secret names. Use the CLI to write local values into `.devflare/secrets.local.json`, then let dev, `createTestContext()`, and `createOfflineEnv(..., { cwd })` read those values locally.' + ], + snippets: [ + { + title: 'Create a local secret value', + language: 'bash', + code: String.raw`devflare secrets --local --store store-123 --name api-token --value local-token +devflare secrets --local --store store-123 --list` + } + ], + bullets: [ + 'The Worker sees a read-only `SecretsStoreSecret` binding.', + 'CLI output lists `store/name` references; it does not print secret values.', + 'Use explicit `{ storeId, secretName }` binding objects only when one Worker needs secrets from multiple stores.' + ] + } + ], + compileOutput: String.raw`{ + "secrets_store_secrets": [ + { "binding": "API_TOKEN", "store_id": "store-123", "secret_name": "api-token" }, + { "binding": "STRIPE_WEBHOOK_SECRET", "store_id": "store-123", "secret_name": "stripe-webhook-secret" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'ai-search', + label: 'AI Search', + categoryDescription: + 'AI Search instance and namespace bindings with fixture-backed local tests and remote relevance boundaries.', + configKey: 'bindings.aiSearch', + authoringShape: + 'Record plus `aiSearchNamespaces` for namespace access', + localStory: + 'Offline-fixture: deterministic in-memory instances can test application flow, not hosted relevance behavior', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/ai-search.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `ai_search` / `ai_search_namespaces`', + envType: '`AiSearchInstance` or `AiSearchNamespace`', + defaultHarness: '`createOfflineEnv()` with AI Search fixtures', + testHelper: '`createMockAISearchInstance()` / `createMockAISearchNamespace()`', + bestFor: + 'search/chat flows where the app calls an AI Search instance or namespace from a Worker', + remoteBoundary: + 'Cloudflare owns crawling, indexing, ranking, and hosted model behavior; local mocks only prove app control flow.', + configSnippet: { + title: 'Smallest AI Search config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs-search' + } + } + } +})` + }, + usageSnippet: { + title: 'Search one AI Search instance', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const query = new URL(request.url).searchParams.get('q') ?? 'devflare' + const result = await env.DOCS_SEARCH.search({ query }) + + return Response.json(result.chunks) +}` + }, + testSnippet: { + title: 'Fixture AI Search results offline', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockAISearchInstance } from 'devflare/test' + +test('finds fixture content', async () => { + const search = createMockAISearchInstance({ + items: [{ key: 'offline.md', content: 'Offline fixtures make tests deterministic' }] + }) + + const result = await search.search({ query: 'fixtures' }) + expect(result.chunks.length).toBeGreaterThan(0) +})` + }, + compileOutput: String.raw`{ + "ai_search": [ + { "binding": "DOCS_SEARCH", "instance_name": "docs-search" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'mtls-certificates', + label: 'mTLS Certificates', + categoryDescription: + 'mTLS certificate Fetcher bindings with local handler fixtures and remote certificate-presentation boundaries.', + configKey: 'bindings.mtlsCertificates', + authoringShape: 'Record', + localStory: + 'Offline-fixture: local tests can model Fetcher behavior, but not real certificate presentation', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `mtls_certificates`', + envType: '`Fetcher`', + defaultHarness: '`createOfflineEnv()` with `fixtures.mtlsCertificates`', + testHelper: '`createMockMTLSCertificate()` / `createMockEnv({ mtlsCertificates })`', + bestFor: 'calling origins that require a Cloudflare-uploaded client certificate', + remoteBoundary: + 'Real TLS client-certificate presentation is Cloudflare/Wrangler remote behavior.', + configSnippet: { + title: 'Smallest mTLS Certificate config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'mtls-worker', + bindings: { + mtlsCertificates: { + CLIENT_CERT: { + certificateId: 'certificate-uuid' + } + } + } +})` + }, + usageSnippet: { + title: 'Fetch through the mTLS binding', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return env.CLIENT_CERT.fetch('https://origin.example/status') +}` + }, + testSnippet: { + title: 'Fixture an mTLS Fetcher locally', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockMTLSCertificate } from 'devflare/test' + +test('uses a local mTLS Fetcher fixture', async () => { + const cert = createMockMTLSCertificate(async () => Response.json({ ok: true })) + const response = await cert.fetch('https://origin.example/status') + + expect(await response.json()).toEqual({ ok: true }) +})` + }, + compileOutput: String.raw`{ + "mtls_certificates": [ + { "binding": "CLIENT_CERT", "certificate_id": "certificate-uuid" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'dispatch-namespaces', + label: 'Dispatch Namespaces', + categoryDescription: + 'Workers for Platforms dispatch bindings with explicit local tenant fetcher fixtures.', + configKey: 'bindings.dispatchNamespaces', + authoringShape: 'Record', + localStory: + 'Offline-fixture: tests can provide named tenant fetchers, but Devflare does not emulate tenant upload/lifecycle', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `dispatch_namespaces`', + envType: '`DispatchNamespace`', + defaultHarness: '`createOfflineEnv()` with `fixtures.dispatchNamespaces`', + testHelper: '`createMockDispatchNamespace()` / `createMockEnv({ dispatchNamespaces })`', + bestFor: 'platform Workers that dispatch to tenant Workers by name', + remoteBoundary: + 'Cloudflare owns dispatch namespace creation, tenant uploads, Worker metadata, and production routing.', + configSnippet: { + title: 'Smallest Dispatch Namespace config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'platform-worker', + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'tenants' + } + } + } +})` + }, + usageSnippet: { + title: 'Dispatch to one tenant Worker', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const tenant = new URL(request.url).searchParams.get('tenant') ?? 'default' + return env.DISPATCHER.get(tenant).fetch(request) +}` + }, + testSnippet: { + title: 'Fixture tenant dispatch locally', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockDispatchNamespace } from 'devflare/test' + +test('dispatches to a configured tenant', async () => { + const dispatcher = createMockDispatchNamespace({ + workers: { + default: async () => new Response('tenant-ok') + } + }) + + expect(await (await dispatcher.get('default').fetch('https://example.com')).text()).toBe('tenant-ok') +})` + }, + compileOutput: String.raw`{ + "dispatch_namespaces": [ + { "binding": "DISPATCHER", "namespace": "tenants" } + ] +}` + }) +] diff --git a/apps/documentation/src/lib/docs/content/bindings/compact-guides-2.ts b/apps/documentation/src/lib/docs/content/bindings/compact-guides-2.ts new file mode 100644 index 0000000..b9a936c --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/compact-guides-2.ts @@ -0,0 +1,584 @@ +import { type BindingGuideDefinition, createCompactBindingGuide } from './shared' + +export const compactBindingGuidesPart2: BindingGuideDefinition[] = [ + createCompactBindingGuide({ + slugBase: 'workflows', + label: 'Workflows', + categoryDescription: + 'Workflow bindings for starting and inspecting workflow instances from Workers.', + configKey: 'bindings.workflows', + authoringShape: 'Record', + localStory: + 'Full local support through Miniflare workflow bindings and deterministic workflow mocks', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts', + 'cases/case16/*' + ], + compileTarget: 'Wrangler `workflows`', + envType: '`Workflow`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()`', + testHelper: '`createMockWorkflow()` / `createMockEnv({ workflows })`', + bestFor: 'starting long-running workflow instances from a Worker path', + remoteBoundary: + 'Cloudflare owns deployed Workflow durability, retries, scheduling, and production instance history.', + configSnippet: { + title: 'Smallest Workflow binding config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workflow-client', + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'order-workflow', + className: 'OrderWorkflow' + } + } + } +})` + }, + usageSnippet: { + title: 'Define and start one order workflow', + language: 'ts', + code: String.raw`import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers' +import { env } from 'devflare/runtime' + +type OrderWorkflowParams = { + orderId: string + email: string +} + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep): Promise { + const invoice = await step.do('create invoice', async () => { + return { id: 'inv_' + event.payload.orderId, email: event.payload.email } + }) + + await step.do('send confirmation', async () => { + await fetch('https://api.example.com/confirmations', { + method: 'POST', + body: JSON.stringify(invoice) + }) + return { queued: true } + }) + + return invoice + } +} + +export async function fetch(request: Request): Promise { + const orderId = new URL(request.url).searchParams.get('order') ?? 'demo' + const instance = await env.ORDER_WORKFLOW.create({ + id: orderId, + params: { orderId, email: 'customer@example.com' } + }) + + return Response.json({ id: instance.id }) +}` + }, + testSnippet: { + title: 'Pure workflow call test', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockWorkflow } from 'devflare/test' + +test('creates a workflow instance', async () => { + const workflow = createMockWorkflow() + const instance = await workflow.create({ id: 'order-1', params: { orderId: 'order-1' } }) + + expect(instance.id).toBe('order-1') +})` + }, + compileOutput: String.raw`{ + "workflows": [ + { "binding": "ORDER_WORKFLOW", "name": "order-workflow", "class_name": "OrderWorkflow" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'pipelines', + label: 'Pipelines', + categoryDescription: + 'Pipeline bindings for event ingestion, with local send recording and Cloudflare-managed sinks.', + configKey: 'bindings.pipelines', + authoringShape: 'Record', + localStory: + 'Offline-native for send-recording tests; Cloudflare owns production batching and sink delivery', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `pipelines`', + envType: '`Pipeline`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()`', + testHelper: '`createMockPipeline()` / `createMockEnv({ pipelines })`', + bestFor: 'Worker-side event ingestion into Cloudflare Pipelines', + remoteBoundary: + 'Devflare records local sends but does not create pipelines, manage R2 sinks, or emulate production batching.', + configSnippet: { + title: 'Smallest Pipeline config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'events-worker', + bindings: { + pipelines: { + EVENTS: 'app-events' + } + } +})` + }, + usageSnippet: { + title: 'Send one record batch', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.EVENTS.send([ + { timestamp: Date.now(), message: 'signup' } + ]) + + return new Response('recorded') +}` + }, + testSnippet: { + title: 'Assert recorded Pipeline sends', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockPipeline } from 'devflare/test' + +test('records sent pipeline rows', async () => { + const pipeline = createMockPipeline() + await pipeline.send([{ message: 'signup' }]) + + expect(pipeline._getRecords()).toEqual([{ message: 'signup' }]) +})` + }, + compileOutput: String.raw`{ + "pipelines": [ + { "binding": "EVENTS", "pipeline": "app-events" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'images', + label: 'Images', + categoryDescription: + 'Cloudflare Images binding docs with singleton config, local chain-shape tests, and hosted-image boundaries.', + configKey: 'bindings.images', + authoringShape: 'Record', + localStory: + 'Full local support through Miniflare image bindings, persisted local state, and deterministic pure mocks', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `images`', + envType: '`ImagesBinding`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()`', + testHelper: '`createMockImagesBinding()` / `createMockEnv({ images })`', + bestFor: 'image transformation/upload paths where the Worker calls the Images binding', + remoteBoundary: + 'Cloudflare owns hosted image storage, variants, delivery rules, billing, and final transform fidelity.', + configSnippet: { + title: 'Smallest Images config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'images-worker', + bindings: { + images: { + IMAGES: true + } + } +})` + }, + usageSnippet: { + title: 'Transform uploaded image bytes', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing image', { status: 400 }) + } + + return env.IMAGES + .input(request.body) + .transform({ width: 320 }) + .output({ format: 'image/jpeg' }) +}` + }, + testSnippet: { + title: 'Pure Images chain-shape test', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockImagesBinding } from 'devflare/test' + +test('returns a deterministic image response', async () => { + const images = createMockImagesBinding() + const result = await images.input(new Blob(['image']).stream()).transform({ width: 320 }).output({ format: 'image/png' }) + + expect(result.response().headers.get('content-type')).toBe('image/png') +})` + }, + compileOutput: String.raw`{ + "images": { + "binding": "IMAGES" + } +}` + }), + createCompactBindingGuide({ + slugBase: 'media-transformations', + label: 'Media Transformations', + categoryDescription: + 'Media Transformations binding docs with local transform-chain support and clear codec fidelity boundaries.', + configKey: 'bindings.media', + authoringShape: 'Record', + localStory: + 'Full local support through Miniflare media bindings and deterministic pure mocks for transform chains', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `media`', + envType: '`MediaBinding`', + defaultHarness: '`createTestContext()` or `createOfflineEnv()` with media fixtures', + testHelper: '`createMockMediaBinding()` / `createMockEnv({ media })`', + bestFor: + 'video/audio transformation paths where the Worker calls Cloudflare Media Transformations', + remoteBoundary: + 'Cloudflare owns real codecs, output fidelity, duration handling, cache behavior, and billing.', + configSnippet: { + title: 'Smallest Media Transformations config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'media-worker', + bindings: { + media: { + MEDIA: true + } + } +})` + }, + usageSnippet: { + title: 'Run one media transformation chain', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing media', { status: 400 }) + } + + return env.MEDIA + .input(request.body) + .transform({ width: 640 }) + .output({ format: 'video/mp4' }) +}` + }, + testSnippet: { + title: 'Pure Media chain-shape test', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockMediaBinding } from 'devflare/test' + +test('returns a deterministic media response', async () => { + const media = createMockMediaBinding() + const result = media.input(new Blob(['media']).stream()).transform({ width: 640 }).output({ mode: 'video' }) + const response = await result.response() + + expect(response.headers.get('content-type')).toBe('video/mp4') +})` + }, + compileOutput: String.raw`{ + "media": { + "binding": "MEDIA" + } +}` + }), + createCompactBindingGuide({ + slugBase: 'artifacts', + label: 'Artifacts', + categoryDescription: + 'Artifacts bindings for Git-compatible file storage, with in-memory repo/token tests.', + configKey: 'bindings.artifacts', + authoringShape: 'Record', + localStory: + 'Offline-fixture: repo metadata and token flows can be modeled in memory, not as real Git remotes', + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts' + ], + compileTarget: 'Wrangler `artifacts`', + envType: '`Artifacts`', + defaultHarness: '`createOfflineEnv()` with artifact fixtures', + testHelper: '`createMockArtifacts()` / `createMockEnv({ artifacts })`', + bestFor: 'Worker-managed repo metadata, temporary tokens, and artifact namespace workflows', + remoteBoundary: + 'Cloudflare owns real Git protocol, durable namespace storage, permissions, and remote URLs.', + configSnippet: { + title: 'Smallest Artifacts config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'artifact-worker', + bindings: { + artifacts: { + ARTIFACTS: 'build-artifacts' + } + } +})` + }, + usageSnippet: { + title: 'Create one Artifacts repository', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const repo = await env.ARTIFACTS.create('run-logs', { + description: 'CI run logs' + }) + + return Response.json({ remote: repo.remote }) +}` + }, + testSnippet: { + title: 'Pure Artifacts repo test', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createMockArtifacts } from 'devflare/test' + +test('creates an in-memory artifact repo', async () => { + const artifacts = createMockArtifacts() + const repo = await artifacts.create('run-logs') + + expect(repo.name).toBe('run-logs') +})` + }, + compileOutput: String.raw`{ + "artifacts": [ + { "binding": "ARTIFACTS", "namespace": "build-artifacts" } + ] +}` + }), + createCompactBindingGuide({ + slugBase: 'containers', + label: 'Containers', + categoryDescription: + 'Cloudflare Containers config plus a Worker route that hands requests to a container-backed Durable Object.', + configKey: 'containers', + authoringShape: 'Array<{ className; image; maxInstances?; instanceType?; imageBuildContext? }>', + localStory: + 'Offline-native only when an explicit Docker/Podman engine is available and the image can run without pulling', + sourcePages: [ + 'packages/devflare/src/config/schema-runtime.ts', + 'packages/devflare/src/config/compiler.ts', + 'packages/devflare/src/test/containers.ts', + 'packages/devflare/src/test/offline-bindings.ts', + 'https://developers.cloudflare.com/containers/get-started/', + 'https://developers.cloudflare.com/containers/platform-details/image-management/', + 'https://developers.cloudflare.com/containers/container-class/', + 'https://developers.cloudflare.com/workers/wrangler/configuration/#containers' + ], + compileTarget: 'Wrangler `containers`', + envType: 'Container class config plus a Durable Object container binding', + defaultHarness: '`devflare/test` containers helpers guarded by `shouldSkip.containers`', + testHelper: '`detectContainerEngine()` / `createContainerManager()` / `containers`', + bestFor: + 'routing requests to a stateful container instance that runs code outside the Workers runtime', + remoteBoundary: + 'Cloudflare owns deployed container rollout, managed registry availability, SSH, scaling, and hosted platform behavior; Devflare owns the Docker/Podman local loop.', + configSnippet: { + title: 'Smallest Containers config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + files: { + fetch: 'src/container.api.ts', + durableObjects: 'src/container.api.ts' + }, + bindings: { + durableObjects: { + API_CONTAINER: { + className: 'ApiContainer' + } + } + }, + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ], + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ApiContainer'] + } + ] +})` + }, + usageSnippet: { + title: 'Proxy one application route to a container instance', + language: 'ts', + code: String.raw`import { Container, getContainer } from '@cloudflare/containers' +import { env } from 'devflare/runtime' + +export class ApiContainer extends Container { + defaultPort = 8080 + sleepAfter = '10m' +} + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const sessionId = url.searchParams.get('session') ?? 'public' + const container = getContainer(env.API_CONTAINER, sessionId) + + return container.fetch(request) +}` + }, + testSnippet: { + title: 'Detect Docker or Podman before running container tests', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { detectContainerEngine } from 'devflare/test' + +test('container engine detection is explicit', async () => { + const status = await detectContainerEngine() + if (!status.available) { + expect(status.reason.length).toBeGreaterThan(0) + return + } + + expect(['docker', 'podman']).toContain(status.engine) +})` + }, + overviewSections: [ + { + id: 'container-image-workflow', + title: 'Build and reference the image deliberately', + paragraphs: [ + 'Devflare treats the `containers` entry as the contract between the Worker class and a real container image. For local work, point `image` at a tag that already exists in Docker or Podman, or point it at a local Dockerfile path that Devflare can build from files on disk.', + 'Cloudflare uses the same container idea in the hosted lane: Wrangler accepts a Dockerfile path or an image reference. Dockerfile paths are built locally and pushed during deploy, while image references can come from the Cloudflare Registry, Docker Hub, or Amazon ECR.' + ], + snippets: [ + { + title: 'Build the local image with Docker or Podman', + language: 'bash', + code: String.raw`docker build -t localhost/devflare-api:latest ./containers/api +docker image inspect localhost/devflare-api:latest + +podman build -t localhost/devflare-api:latest ./containers/api +podman image inspect localhost/devflare-api:latest` + }, + { + title: 'Reference that local image from Devflare config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ] +})` + }, + { + title: 'Use a Dockerfile or registry image for the Cloudflare lane', + language: 'bash', + code: String.raw`wrangler containers build ./containers/api -t devflare-api:latest +wrangler containers push devflare-api:latest + +# Cloudflare can also reference registry images such as: +# registry.cloudflare.com//devflare-api:latest +# docker.io/library/nginx:alpine +# .dkr.ecr..amazonaws.com/devflare-api:latest` + } + ], + bullets: [ + 'Use `image: "./containers/api/Dockerfile"` or `image: "./containers/api"` when you want Wrangler deploy to build and push from source.', + 'Use `image: "localhost/devflare-api:latest"` for a local tag that Docker or Podman can inspect without a network pull.', + 'Use `registry.cloudflare.com//:` for Cloudflare Registry images, Docker Hub names such as `docker.io/library/nginx:alpine`, or Amazon ECR image references when the hosted deploy should pull a prebuilt image.', + 'Use `wrangler containers registries configure` when the image lives in a private external registry.' + ] + }, + { + id: 'container-local-requirements', + title: 'Full local support requirements', + paragraphs: [ + 'Full local support means Devflare can build, launch, call, inspect, and clean up the container without Cloudflare when the local machine has a working Docker or Podman engine.', + 'The offline-first default is strict: Dockerfile builds use cached base layers, and image references must already exist locally. Set `offline: false` only when the test is allowed to pull from a registry.' + ], + snippets: [ + { + title: 'Run a container-backed route test only when the engine is available', + language: 'ts', + code: String.raw`import { afterAll, expect, test } from 'bun:test' +import { containers, shouldSkip } from 'devflare/test' + +const skipContainers = await shouldSkip.containers + +afterAll(() => containers.stopAll()) + +test.skipIf(skipContainers)('proxies to the local API container', async () => { + const api = await containers.start('ApiContainer', { + configPath: 'devflare.config.ts', + port: 8080, + offline: true + }) + + const response = await api.fetch('/health') + expect(response.status).toBe(200) +})` + } + ], + bullets: [ + 'Install Docker or Podman and make sure `docker info` or `podman info` succeeds before running container tests.', + 'Set `DEVFLARE_CONTAINER_TESTS=1` for test lanes that are allowed to start local containers.', + 'Gate CI and hosted runners with `shouldSkip.containers` because GitHub Actions, Cloudflare runners, and preview workers may not expose a usable container engine.', + 'Keep base images cached when running offline. A missing local tag or uncached base layer is a setup problem, not a reason to silently reach out to a registry.' + ] + } + ], + compileOutput: String.raw`{ + "containers": [ + { "class_name": "ApiContainer", "image": "localhost/devflare-api:latest", "max_instances": 1 } + ], + "durable_objects": { + "bindings": [ + { "name": "API_CONTAINER", "class_name": "ApiContainer" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["ApiContainer"] } + ] +}` + }) +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-1.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-1.ts new file mode 100644 index 0000000..73c4719 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-1.ts @@ -0,0 +1,651 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart1: BindingGuideDefinition[] = [ + { + slugBase: 'kv', + label: 'KV', + categoryDescription: + 'Fast lookup state, cache-like reads, and lightweight shared data with strong local support.', + configKey: 'bindings.kv', + authoringShape: 'Record', + localStory: 'Local runtime and tests', + sourcePages: [ + 'schema-bindings.ts', + 'schema-normalization.ts', + 'resource-resolution.ts', + 'simple-context.ts', + 'apps/testing/*' + ], + overview: { + readTime: '4 min read', + title: 'Use KV for fast lookup state without losing a real local loop', + summary: + 'Author stable KV names in config, keep env typed, and run real get or put flows locally.', + description: + 'Devflare lets you keep KV intent human-readable in `devflare.config.ts` and only resolve opaque namespace ids when build or deploy flows actually need them.', + highlights: [ + 'String shorthand and `{ name }` keep namespace intent readable in source.', + '`createTestContext()` wires KV into the real env contract used by worker code.', + 'Preview-scoped KV names can be materialized and lifecycle-managed automatically.', + '`devflare types` keeps `env.d.ts` aligned with the bindings you actually declared.' + ], + bestFor: 'Cache-like lookups, sessions, feature flags, and lightweight request metadata', + authoringParagraphs: [ + 'KV is happiest when you keep the namespace name stable in authored config and let Devflare resolve ids later. That keeps reviews readable and avoids hiding infrastructure intent in random environment variables.', + 'When you truly already know the namespace id, Devflare accepts that too. The important part is that both shapes compile down to the same deploy-facing contract.' + ], + authoringSnippet: { + title: 'KV authoring with stable names or explicit ids', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kv-worker', + bindings: { + kv: { + CACHE: 'cache-kv', + SESSIONS: { name: 'sessions-kv' }, + REPORTING_CACHE: { id: 'kv-namespace-id' } + } + } +})` + }, + fitBullets: [ + 'Reach for KV when reads are by key and you do not need relational queries.', + 'It is a good home for feature flags, lightweight session markers, or cache records that are cheap to recompute.', + 'If you need SQL, batch transactions, or richer query patterns, use D1 instead of forcing KV to act like a database.' + ], + caveatBullets: [ + 'Rerun `devflare types` after adding or renaming a binding so the generated env contract stays honest.', + 'Preview-scoped names work well for namespace-per-branch flows, but they are still a naming strategy worth reviewing.', + 'KV is local-friendly, but account-level provisioning behavior still belongs in build, preview, or deploy checks when the lifecycle matters.' + ], + caveatCallout: { + tone: 'info', + title: 'The safest authoring instinct', + body: [ + 'Prefer stable names in source and let Devflare resolve ids later. It keeps config readable without giving up deploy-ready output.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'KV goes through the full Devflare pipeline: normalize authoring, resolve names when needed, then compile to Wrangler output.', + description: + 'The important detail is that Devflare does not force ids too early. It keeps stable names readable in source and only turns them into deploy-ready output in flows that truly require it.', + highlights: [ + 'String shorthand is treated as a stable namespace name.', + 'Name-based bindings stay name-based until a build or deploy flow resolves them.', + 'Local runtime can wire KV without Cloudflare lookup when all you need is a local namespace identifier.', + 'Compile emits Wrangler-compatible `kv_namespaces`.' + ], + normalizationFact: 'String and `{ name }` forms both normalize to name-based bindings first', + compileTarget: 'Wrangler `kv_namespaces`', + previewNote: 'Preview-scoped KV namespaces can be provisioned and cleaned up automatically', + normalizationParagraphs: [ + '`bindings.kv` accepts a plain string, `{ name }`, or `{ id }`. Devflare normalizes those into one internal shape so later code can reason about them consistently.', + 'Authored config can stay human-readable without making compiler or deploy code guess what each record means at the last second.' + ], + localRuntimeBullets: [ + 'Local runtime resolution can keep the configured name as the local namespace identifier instead of forcing a Cloudflare API lookup.', + 'The env proxy supports the real KV methods you expect in worker code, including `get`, `put`, `delete`, `list`, and `getWithMetadata`.', + 'If you only need isolated unit tests, the repo also exposes `createMockKV()` and `createMockEnv()` helpers.' + ], + compileBullets: [ + 'Build and deploy flows resolve stable namespace names into ids when the output must be Wrangler-ready.', + 'If unresolved name-based KV bindings remain at compile time, Devflare rejects the config instead of silently guessing.', + 'Preview-scoped KV names are treated as lifecycle-managed resources, so branch-specific namespaces can be provisioned and cleaned up deliberately.' + ], + callout: { + tone: 'success', + title: 'Why the split matters', + body: [ + 'Authored config can stay stable and readable even though deploy output eventually needs concrete ids. That separation is a big part of why KV feels pleasant in Devflare.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Use the default test harness first. KV is one of the bindings Devflare supports best in local tests.', + description: + 'When you call `createTestContext()`, KV namespaces are wired into the same env contract your worker code uses. That lets you test reads and writes without inventing a fake abstraction first.', + highlights: [ + '`createTestContext()` is usually enough for meaningful KV tests.', + 'Use `env.CACHE` directly for fast binding-focused checks.', + 'Use `cf.worker.fetch()` when the binding matters as part of a route or handler flow.', + 'Mock helpers exist, but the default local harness is usually better.' + ], + bestFor: 'Worker tests that read and write real KV values through the local harness', + defaultHarness: '`createTestContext()` plus `env.CACHE` or `cf.worker.fetch()`', + escalation: 'You need to verify provisioning, preview naming, or account-side behavior', + paragraphs: [ + 'Start small: create the test context, write a value, read it back, and only then move outward to HTTP or queue-driven flows.', + 'If the binding matters because a route uses it, test through that route. If the binding itself is the thing you are verifying, talk to `env.CACHE` directly.' + ], + mainSnippet: { + title: 'Testing KV through the real Devflare env', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('stores and reads a cache value', async () => { + await env.CACHE.put('feature:search', 'on') + expect(await env.CACHE.get('feature:search')).toBe('on') +})` + }, + helperBullets: [ + 'Use `env.CACHE` or the specific KV binding directly when you want the shortest binding-focused assertion.', + 'Use `cf.worker.fetch()` if the behavior only matters once a request has gone through your real handler.', + 'Use `createMockKV()` only when the test truly should not boot the runtime-shaped harness.' + ], + caveatBullets: [ + 'Local KV tests are excellent for behavior and shape, but they do not replace deploy-time checks for account provisioning or preview cleanup.', + 'If a test is really about routing, auth, or caching headers, keep the assertion at the worker level instead of overfocusing on the namespace API.', + 'Preview-specific namespace naming is worth one dedicated integration check when branch isolation matters.' + ], + callout: { + tone: 'accent', + title: 'A good default split', + body: [ + 'Test binding semantics locally and test lifecycle semantics in preview or deploy-oriented paths. Trying to make one test do both usually makes it worse at each job.' + ] + } + }, + example: { + readTime: '3 min read', + summary: 'This example keeps KV simple: one binding, one fetch handler, one assertion.', + description: + 'The fastest way to trust a binding is to wire one small use case end to end before you hide it behind a bigger app.', + highlights: [ + 'One binding in config is enough to learn the shape.', + 'A simple `put()` plus `get()` route already proves the local story.', + 'The first version should be about clarity, not cache invalidation genius.', + 'You can keep this same pattern while the app grows.' + ], + configFocus: 'Stable namespace naming', + runtimeShape: 'Direct `put()` and `get()` calls in a fetch handler', + bestUse: 'A tiny cache or session-marker flow', + configSnippet: { + title: 'Minimal KV config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kv-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + CACHE: 'cache-kv' + } + } +})` + }, + usageSnippet: { + title: 'A tiny fetch handler that uses KV', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/write') { + await env.CACHE.put('hello', 'from-kv') + return new Response('stored') + } + + return new Response((await env.CACHE.get('hello')) ?? 'missing') +}` + }, + notes: [ + 'Run `devflare types` once the binding exists so `env.CACHE` is typed in both worker code and tests.', + 'Prefer a tiny route like this before you wrap KV behind a helper or service layer.' + ], + callout: { + tone: 'info', + title: 'Start with the boring shape', + body: [ + 'If the first KV example already feels abstract, it is probably hiding the actual binding semantics instead of teaching them.' + ] + } + } + }, + { + slugBase: 'd1', + label: 'D1', + categoryDescription: + 'SQLite-style relational queries with a strong local harness and id or name-based authoring.', + configKey: 'bindings.d1', + authoringShape: 'Record', + localStory: 'Local runtime and tests', + sourcePages: [ + 'schema-bindings.ts', + 'schema-normalization.ts', + 'resource-resolution.ts', + 'simple-context.ts', + 'case18/*' + ], + overview: { + readTime: '4 min read', + title: 'Use D1 when the worker wants real queries instead of key-value tricks', + summary: + 'D1 gets the same stable-name authoring story as KV, but the runtime shape is relational: `prepare`, `batch`, `exec`, and prepared statements.', + description: + 'Devflare keeps D1 readable in config and testable in local runtime, which means you can model actual query behavior before you wire up preview or deploy steps.', + highlights: [ + 'String shorthand means a stable database name, not a magic hidden id.', + 'Local runtime supports the D1 methods developers actually use in worker code.', + 'Build and deploy can resolve names to ids when they need Wrangler-ready output.', + 'Preview-scoped D1 names can be lifecycle-managed when branch isolation matters.' + ], + bestFor: 'Structured data, SQL queries, and cases where key-based lookup is not enough', + authoringParagraphs: [ + 'D1 follows the same stable-name instinct as KV: author by readable name unless you intentionally already have a database id you want to pin to.', + 'In reviews, look for human-meaningful names in source. Inspect generated or resolved output only when a deploy flow needs it.' + ], + authoringSnippet: { + title: 'D1 binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'd1-worker', + bindings: { + d1: { + DB: 'app-db', + AUDIT: { name: 'audit-db' }, + REPORTING: { id: 'd1-database-id' } + } + } +})` + }, + fitBullets: [ + 'Use D1 when the worker needs SQL, joins, or a schema that should be queried instead of fetched by a single key.', + 'It fits better than KV for records that need filtering, ordering, or transactional updates.', + 'If the only operation is key lookup or a tiny cache record, KV usually stays simpler.' + ], + caveatBullets: [ + 'Run `devflare types` after binding changes so the database bindings show up correctly in `env.d.ts`.', + 'Preview-scoped databases are useful when branch data must stay isolated, but they should still be provisioned and cleaned up deliberately.', + 'Name-based D1 authoring is readable, but build and deploy still need a path that resolves those names to ids before output is treated as final.' + ], + caveatCallout: { + tone: 'info', + title: 'Do not hide the database shape', + body: [ + 'The point of D1 docs is to keep SQL visible enough that reviewers can still understand what the worker is doing, not to hide every query behind framework glue.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'D1 uses the same normalize-then-resolve pattern as KV, but compiles to Wrangler `d1_databases` and exposes a relational local runtime surface.', + description: + 'The key implementation detail is that Devflare can keep a stable database name around until a flow truly needs the real database id. That keeps config readable without giving up deploy precision.', + highlights: [ + 'String shorthand and `{ name }` both normalize to name-based D1 bindings first.', + 'Local runtime can wire D1 without forcing Cloudflare lookups up front.', + 'Compile emits `d1_databases` after resolution.', + 'Prepared statements, `batch()`, and `exec()` are all part of the supported local runtime story.' + ], + normalizationFact: + 'Name-based authoring stays name-based until a build or deploy flow resolves it', + compileTarget: 'Wrangler `d1_databases`', + previewNote: 'Preview-scoped D1 databases can be provisioned and cleaned up by Devflare', + normalizationParagraphs: [ + 'Like KV, D1 bindings normalize into one internal shape so compiler and runtime code do not need to special-case string versus object authoring everywhere.', + 'That normalized form is what lets Devflare keep the friendly source-of-truth shape while still generating strict Wrangler-facing output later.' + ], + localRuntimeBullets: [ + 'The local bridge exposes the D1 APIs people actually use: `prepare()`, `batch()`, `exec()`, and the prepared-statement helpers like `first`, `all`, `run`, and `raw`.', + '`createTestContext()` can boot those bindings without a custom mock layer, which is why D1 tests can stay close to production query code.', + 'If you only need isolated unit tests, `createMockD1()` exists, but it is usually weaker than the full runtime-shaped harness.' + ], + compileBullets: [ + 'Build and deploy resolve name-based D1 records to real database ids before Devflare emits compiled config.', + 'Compile rejects unresolved name-based D1 bindings instead of silently producing half-finished Wrangler output.', + 'Preview resource management can create and later remove branch-specific D1 databases when the preview model truly owns separate data.' + ], + callout: { + tone: 'success', + title: 'Same authoring rule, different runtime shape', + body: [ + 'The config story is close to KV, but the runtime story is SQL-shaped โ€” as it should be.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'D1 is one of the easiest bindings to test meaningfully with Devflare because the local runtime already speaks the same database API your worker uses.', + description: + 'Start with `createTestContext()`, then either query the database directly through `env.DB` or exercise it through your real routes. Both are normal, not exotic.', + highlights: [ + 'Use the local harness before you build fake database abstractions.', + '`env.DB.prepare(...).first()` is already a good binding test.', + 'Worker-level tests are better when SQL behavior only matters through an HTTP or queue path.', + 'Escalate to integration only when schema migrations or account-side provisioning are the real question.' + ], + bestFor: 'Query behavior, route-level database flows, and schema-aware worker tests', + defaultHarness: '`createTestContext()` with `env.DB` or `cf.worker.fetch()`', + escalation: 'You need migration, provisioning, or branch-scoped preview verification', + paragraphs: [ + 'The cleanest D1 test loop mirrors how the worker really behaves: boot the test context, run a small query, and assert the returned row or route result.', + 'If a helper wraps the query logic, keep one direct database test around anyway so the underlying binding contract stays visible.' + ], + mainSnippet: { + title: 'A tiny D1 test through the local harness', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('D1 answers a simple health query', async () => { + const row = await env.DB.prepare('select 1 as ok').first<{ ok: number }>() + expect(row?.ok).toBe(1) +})` + }, + helperBullets: [ + 'Use `env.DB` when the binding itself is the thing you care about.', + 'Use `cf.worker.fetch()` when the database matters because a route, queue consumer, or other handler reaches it.', + 'Keep the schema setup close to the test when possible so the query story stays visible.' + ], + caveatBullets: [ + 'Local tests are excellent for query logic, but they are not a substitute for migration review or account-side database provisioning checks.', + 'If the assertion is really about a business route, do not collapse the entire behavior down to one raw SQL assertion and pretend that is the full story.', + 'Preview-specific D1 isolation is worth its own higher-level check when branch data boundaries matter.' + ], + callout: { + tone: 'warning', + title: 'Do not let SQL disappear into helper fog', + body: [ + 'One reason D1 feels good in Devflare is that the runtime API is still recognizable. Keep at least one test close enough to see the actual query behavior.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This starter example keeps D1 focused on one job: answer a single query and prove the binding works locally.', + description: + 'You do not need a giant ORM story to prove D1 is wired correctly. One table-shaped query is already enough to make the point.', + highlights: [ + 'One binding plus one query proves the setup.', + 'The same shape scales into larger route handlers later.', + 'Keep SQL visible in the example so the binding story stays honest.', + 'If the app grows, you can still keep one tiny D1 route as a smoke path.' + ], + configFocus: 'Stable database naming', + runtimeShape: 'Prepared statement query in a fetch handler', + bestUse: 'Health checks, small lookup routes, and early schema experiments', + configSnippet: { + title: 'Minimal D1 config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'd1-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + d1: { + DB: 'app-db' + } + } +})` + }, + usageSnippet: { + title: 'A tiny route that proves the binding works', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const row = await env.DB.prepare('select 1 as ok').first<{ ok: number }>() + return Response.json({ ok: row?.ok === 1 }) +}` + }, + notes: [ + 'You can replace the health query with a real table lookup later without changing the binding shape.', + 'Keep one route like this around if you want a cheap deploy smoke path for D1.' + ], + callout: { + tone: 'info', + title: 'The first example does not need a migration epic', + body: [ + 'Prove the binding first. Add richer schema setup only after the worker already has one truthful D1 path.' + ] + } + } + }, + { + slugBase: 'r2', + label: 'R2', + categoryDescription: + 'Object storage bindings with strong local support and one important rule: do not assume a browser URL contract.', + configKey: 'bindings.r2', + authoringShape: 'Record', + localStory: 'Local runtime and tests', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'simple-context.ts', + 'packages/devflare/src/test/simple-context.ts', + 'apps/testing/*' + ], + overview: { + readTime: '4 min read', + title: 'Use R2 for object storage, but route browser delivery deliberately', + summary: + 'R2 is straightforward in config and well-supported locally, but browser-facing delivery should usually go through a Worker route instead of assuming bucket URLs.', + description: + 'R2 works in worker code and tests. The main discipline is deciding which files are public, which are private, and which paths should stay app-controlled.', + highlights: [ + 'R2 authoring is intentionally simple: binding name to bucket name.', + 'Local runtime supports `head`, `get`, `put`, `delete`, and `list`.', + 'Preview-scoped bucket names can be materialized and lifecycle-managed.', + 'Devflare does not promise a stable browser-facing local bucket URL contract.' + ], + bestFor: 'Files, uploads, generated assets, and private object delivery through a Worker', + authoringParagraphs: [ + 'R2 is the least ambiguous storage binding to author: you bind a name in env to a bucket name in config.', + 'The real architectural choice is not the config key. It is whether the browser talks to a public bucket, a signed upload path, or a worker-controlled route that checks auth first.' + ], + authoringSnippet: { + title: 'R2 binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'r2-worker', + bindings: { + r2: { + ASSETS: 'assets-bucket', + PRIVATE_FILES: 'private-files-bucket' + } + } +})` + }, + fitBullets: [ + 'Use R2 for large objects, uploads, or file delivery that does not belong in D1 or KV.', + 'Keep private file delivery in a Worker route so auth and response headers stay under your control.', + 'If the browser needs a direct public asset origin, use a public bucket on a custom domain rather than by accident.' + ], + caveatBullets: [ + 'Do not assume local bucket URLs are a public contract your app can safely depend on.', + 'Use `devflare types` after binding changes so bucket names show up correctly in `env.d.ts`.', + 'Preview-scoped buckets are useful, but they should still be cleaned up intentionally when previews expire.' + ], + caveatCallout: { + tone: 'warning', + title: 'The browser-delivery rule', + body: [ + 'If the browser needs the file in local dev, route through your worker unless you intentionally chose a public bucket contract.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'R2 is simpler than KV or D1 because the authored value is already the bucket name, so there is no name-versus-id resolution dance.', + description: + 'That simplicity is part of why R2 feels predictable in Devflare. The runtime and compiler story mostly focuses on wiring methods and generated output cleanly, not on translating names into ids.', + highlights: [ + '`bindings.r2` is just a record of binding name to bucket name.', + 'Compile emits Wrangler `r2_buckets` directly.', + 'The bridge supports the core object methods and can move large puts over HTTP when needed.', + 'Preview-scoped bucket names are part of the managed preview resource story.' + ], + normalizationFact: 'There is no separate id-resolution phase for the authored bucket name', + compileTarget: 'Wrangler `r2_buckets`', + previewNote: 'Preview-scoped buckets can be provisioned and cleaned up by Devflare', + normalizationParagraphs: [ + 'R2 is one of the cleanest bindings internally because the authored string is already the thing Wrangler expects later: the bucket name.', + 'That means Devflare mostly needs to preserve the mapping faithfully, generate output, and expose the runtime methods cleanly in local mode.' + ], + localRuntimeBullets: [ + 'The local bridge supports `head`, `get`, `put`, `delete`, and `list` on R2 buckets.', + 'Large `put()` operations can switch to HTTP transfer inside the bridge rather than trying to force every object body through one RPC path.', + '`createMockR2()` exists for isolated tests, but the real local harness is usually the better default.' + ], + compileBullets: [ + 'Compile emits `r2_buckets` directly from the authored mapping.', + 'Preview resource lifecycle code can materialize branch-scoped bucket names, provision them, and later clean them up.', + 'The browser URL story is intentionally left to your app architecture rather than being smuggled into the binding implementation.' + ], + callout: { + tone: 'info', + title: 'Simple binding, nontrivial delivery choices', + body: [ + 'R2 config is easy. The interesting decisions are about how files flow through your app, not about how many nested objects the config needs.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'R2 is local-friendly, which means you can test real object operations without inventing a storage adapter just to get off the ground.', + description: + 'Use the runtime-shaped harness for direct bucket tests, then move up to worker-level tests when headers, auth, or file routing matter.', + highlights: [ + 'Bucket operations work through the local harness.', + 'Worker-level tests are the right place for auth or response-header behavior.', + 'The local story is strong, but public asset delivery still needs architectural intent.', + 'Use preview or deploy checks when the real question is bucket provisioning or cleanup.' + ], + bestFor: 'Object reads, writes, deletes, and route-level file-serving checks', + defaultHarness: '`createTestContext()` with `env.ASSETS` or `cf.worker.fetch()`', + escalation: 'You need to verify public delivery contracts or preview resource lifecycle', + paragraphs: [ + 'R2 tests can be extremely small: put one object, read it back, and confirm the content or headers through the same worker path users will actually hit.', + 'That is often enough to prove the binding, while the route test proves your app-level delivery rules.' + ], + mainSnippet: { + title: 'Testing a real R2 binding', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('stores and reads an object', async () => { + await env.ASSETS.put('hello.txt', 'from-r2') + const object = await env.ASSETS.get('hello.txt') + expect(await object?.text()).toBe('from-r2') +})` + }, + helperBullets: [ + 'Use `env.ASSETS` when you are verifying the bucket contract itself.', + 'Use `cf.worker.fetch()` when the route, auth, or response metadata is the thing that matters.', + 'Keep at least one test close to the bucket API so the storage shape stays visible.' + ], + caveatBullets: [ + 'A passing local bucket test does not mean your public asset topology is good; that still belongs to route and deployment design.', + 'If the browser-facing path matters, assert the worker response instead of treating a bucket read as the whole user story.', + 'Bucket provisioning and cleanup belong in preview or deploy-oriented checks when branch infrastructure matters.' + ], + callout: { + tone: 'warning', + title: 'Test the right layer', + body: [ + 'An object round-trip proves the binding. It does not automatically prove your file-delivery architecture.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example uses one private bucket and one route, which is still the cleanest default shape for many real apps.', + description: + 'A good first R2 example teaches both the binding and the delivery boundary: the worker decides what the browser gets.', + highlights: [ + 'One bucket plus one route is enough to teach the real shape.', + 'Private delivery through a Worker is a strong default.', + 'Headers are part of the example because files are not just bytes.', + 'You can grow into signed uploads or public assets later.' + ], + configFocus: 'Direct bucket naming', + runtimeShape: 'Get an object from R2 and stream it through a route', + bestUse: 'Private file delivery or media endpoints', + configSnippet: { + title: 'Minimal R2 config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'r2-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + r2: { + FILES: 'private-files' + } + } +})` + }, + usageSnippet: { + title: 'Serve an object through the worker', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const key = url.pathname.replace(/^\/files\//, '') + const object = await env.FILES.get(key) + + if (!object) { + return new Response('Not found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream' + } + }) +}` + }, + notes: [ + 'This route pattern keeps auth, caching, and content-type decisions in your app instead of in an assumed bucket URL contract.', + 'If you later choose a public bucket, make that an explicit architecture decision rather than a hidden side effect.' + ], + callout: { + tone: 'info', + title: 'A better first instinct than โ€œjust use the bucket URLโ€', + body: [ + 'Routing through the worker teaches the real boundary between stored objects and browser-facing responses.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-2.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-2.ts new file mode 100644 index 0000000..8a41dd6 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-2.ts @@ -0,0 +1,525 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart2: BindingGuideDefinition[] = [ + { + slugBase: 'durable-object', + label: 'Durable Objects', + categoryDescription: + 'Stateful coordination primitives with strong local support, cross-worker wiring, and important preview caveats.', + configKey: 'bindings.durableObjects', + authoringShape: 'Record', + localStory: 'Local runtime and tests, including cross-worker references', + sourcePages: [ + 'schema-bindings.ts', + 'ref.ts', + 'do-bundler.ts', + 'simple-context.ts', + 'packages/devflare/src/cli/commands/deploy.ts' + ], + overview: { + readTime: '5 min read', + title: + 'Use Durable Objects when coordination or state really belongs with a single object identity', + summary: + 'The fast Devflare payoff is simple: put one counter object in a `do.*` file, call it from the worker, and call the same object directly in tests.', + description: + 'Devflare auto-discovers `**/do.*.{ts,js}` by default, wires the Durable Object binding into the worker env, and lets tests use the same namespace without making you invent a fake DO harness first.', + highlights: [ + 'You can start with one `src/do.counter.ts` file and skip custom DO file-glob config entirely.', + "Worker code can call `env.COUNTER.getByName('main').increment()` directly.", + 'Tests can call that same DO method through the default Devflare harness.', + 'Devflare still handles the bundling, generated types, and Wrangler binding shape underneath.' + ], + bestFor: + 'Stateful sessions, locks, room state, and coordination that should not be faked as random stateless requests', + authoringParagraphs: [ + 'The easiest honest starting point is one local Durable Object class and one binding that points at it by class name.', + 'If the class lives in a `do.*` file, Devflare discovers it with the default `**/do.*.{ts,js}` pattern, so the first example does not need extra DO file config.' + ], + authoringSnippet: { + title: 'Start with one discovered Durable Object and one binding', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'counter-worker', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +})` + }, + fitBullets: [ + 'Use Durable Objects when state or coordination should live behind one object identity, not when you merely want a fancy singleton.', + 'They are a good fit for counters, rooms, distributed locks, and request serialization.', + 'If the state is really just data you query, D1 or KV may stay simpler and easier to preview.' + ], + caveatBullets: [ + 'DO-heavy apps need extra preview care because same-worker preview URLs do not cover every real DO deployment case.', + '`wrangler versions upload` does not currently apply Durable Object migrations, so migration-sensitive previews need a stronger plan.', + 'Test and review worker naming carefully when DO bindings cross worker boundaries.' + ], + caveatCallout: { + tone: 'warning', + title: 'The preview caveat is real, not optional trivia', + body: [ + 'If previews must exercise real Durable Object behavior, branch-scoped preview workers are often safer than hoping same-worker preview URLs will be enough.' + ] + } + }, + internals: { + readTime: '4 min read', + summary: + 'Durable Object bindings normalize into a stable binding shape, compile into Wrangler `durable_objects.bindings`, and participate in Devflareโ€™s own DO bundling path.', + description: + 'This is one of the places where Devflare feels the most application-aware. It is not only compiling config โ€” it is discovering DO classes, bundling them, and keeping local runtime behavior coherent.', + highlights: [ + 'String shorthand becomes `{ className }` in the normalized shape.', + 'Cross-worker bindings can carry `__ref` metadata and be resolved through the referenced worker config.', + 'DO bundling and transform steps are part of the build pipeline, not just a config pass-through.', + 'Compile emits `durable_objects.bindings` with `class_name` and optional `script_name`.' + ], + normalizationFact: + 'Local strings, explicit objects, and cross-worker refs normalize into one DO binding model', + compileTarget: 'Wrangler `durable_objects.bindings`', + previewNote: + 'DO apps often need branch-scoped preview workers instead of same-worker preview URLs', + normalizationParagraphs: [ + 'DO bindings accept a string, an explicit `{ className, scriptName? }` object, or a cross-worker reference produced by `ref()`. Devflare normalizes those into one internal shape before later steps inspect them.', + 'That normalized shape is what lets config, compiler, and test-context setup all speak the same language even when a DO comes from another worker package.' + ], + localRuntimeBullets: [ + 'The local test context can auto-detect cross-worker DO refs and stand up the required multi-worker Miniflare shape for them.', + 'The DO bundler discovers classes from `files.durableObjects`, emits worker-compatible code, and even handles special cases like `@cloudflare/puppeteer` usage.', + 'Tests can use the normal DO namespace ergonomics instead of a custom fake API surface.' + ], + compileBullets: [ + 'Compile emits `class_name` and optional `script_name` for each binding, which is what Wrangler-facing output expects.', + 'Cross-worker DO references are resolved before compile output is treated as final.', + 'Preview and deploy workflows need to respect real DO migration and preview caveats instead of pretending the platform limitations disappeared.' + ], + callout: { + tone: 'accent', + title: 'This is where coherent tooling matters most', + body: [ + 'If a tool cannot keep DO authoring, local runtime, and test setup coherent, DO-heavy apps get painful fast. Devflareโ€™s value is that these pieces stay part of one story.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Durable Objects are well-supported in the default Devflare harness, which means you can test real object behavior without hand-building a fake namespace first.', + description: + 'That support extends to cross-worker DO scenarios too, as long as the config relationships are explicit. The main testing question is whether you are checking local object behavior or deployment caveats.', + highlights: [ + 'Use the default harness before inventing a custom DO mock layer.', + 'Cross-worker DO bindings can still work in the test context when `ref()` wiring is explicit.', + 'Object-level behavior can be tested locally with real namespace and direct method calls.', + 'Preview caveats still need higher-level validation.' + ], + bestFor: 'Local stateful behavior, object methods, and cross-worker DO wiring checks', + defaultHarness: '`createTestContext()` with the real DO namespace in `env`', + escalation: 'The question is preview URLs, migrations, or branch-scoped deploy behavior', + paragraphs: [ + 'Start by creating the test context and calling the object through its real namespace. That proves the binding, the identity lookup, and the object behavior in one go.', + 'Keep one test close to the object semantics even if your app later wraps DO access behind services or helper modules.' + ], + mainSnippet: { + title: 'Testing a Durable Object through the real namespace', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('the counter object increments', async () => { + const counter = env.COUNTER.getByName('main') + expect(await counter.increment()).toBe(1) + expect(await counter.increment()).toBe(2) + expect(await counter.getValue()).toBe(2) +})` + }, + helperBullets: [ + 'Use the real DO namespace in `env` whenever possible instead of a fake interface.', + 'If the object is reached through a route or another worker, keep a worker-level test around as well.', + 'Use cross-worker refs in config rather than loose string conventions so the test context can understand the relationship.' + ], + caveatBullets: [ + 'Local DO tests do not replace migration reviews or branch-scoped preview checks.', + 'If the real risk is deployment naming or preview topology, write a higher-level preview test instead of stretching the local harness past its job.', + 'DO apps often need stronger preview isolation than a same-worker upload path can give them.' + ], + callout: { + tone: 'warning', + title: 'Separate object behavior from preview behavior', + body: [ + 'The default harness is excellent for object logic. It is not a substitute for the preview strategy decisions that DO-heavy apps still need.' + ] + } + }, + example: { + readTime: '4 min read', + summary: + 'This example shows the whole Durable Object story in the smallest useful shape: one auto-discovered object, one worker route, and one direct test.', + description: + 'A counter is enough to show why Devflare is valuable here: you do not need custom DO glue just to get a real local loop. The same `env.COUNTER` namespace works in the worker and in tests.', + highlights: [ + 'One `do.*` file plus one binding is enough to learn the surface.', + 'The example uses Devflareโ€™s default DO discovery pattern instead of extra file-glob ceremony.', + 'The worker can increment the object and the test can call the same object directly.', + 'The first example proves the state model without dragging in a chat app or a fake RPC layer.' + ], + configFocus: 'Auto-discovered `do.*` file plus one DO binding', + runtimeShape: 'Direct namespace method calls from the worker and the test harness', + bestUse: 'Counters, room state, and small single-identity coordination examples', + configSnippet: { + title: 'Minimal Durable Object config using the default discovery pattern', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'do-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +}) + +// Devflare auto-discovers src/do.counter.ts via the default: +// durableObjects: '**/do.*.{ts,js}'` + }, + usageSnippet: { + title: 'A tiny object and one worker path', + language: 'ts', + activeFile: 'src/fetch.ts', + structure: [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/do.counter.ts' }, + { path: 'src/fetch.ts' } + ], + files: [ + { + path: 'src/do.counter.ts', + language: 'ts', + code: String.raw`import { DurableObject } from 'cloudflare:workers' + +${'export'} ${'class'} ${'Counter'} extends DurableObject { + async increment(amount = 1): Promise { + const current = (await this.ctx.storage.get('value')) ?? 0 + const next = current + amount + await this.ctx.storage.put('value', next) + return next + } + + async getValue(): Promise { + return (await this.ctx.storage.get('value')) ?? 0 + } +}` + }, + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const counter = env.COUNTER.getByName('main') + + if (url.pathname === '/value') { + return Response.json({ value: await counter.getValue() }) + } + + return Response.json({ value: await counter.increment() }) +}` + } + ], + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const counter = env.COUNTER.getByName('main') + + if (url.pathname === '/value') { + return Response.json({ value: await counter.getValue() }) + } + + return Response.json({ value: await counter.increment() }) +}` + }, + notes: [ + 'This tiny shape already proves that the object class, namespace, storage, and worker path are wired correctly.', + 'Once this works, richer room, session, or lock logic becomes a normal extension instead of a blind leap.' + ], + callout: { + tone: 'info', + title: 'This is the valuable bit', + body: [ + 'You do not need a chat app to feel the Devflare advantage. One counter already proves that DO files, env bindings, and tests stay part of one simple loop.' + ] + } + } + }, + { + slugBase: 'queue', + label: 'Queues', + categoryDescription: + 'Producer and consumer bindings for background work with a strong local trigger story.', + configKey: 'bindings.queues', + authoringShape: '{ producers?: Record; consumers?: QueueConsumer[] }', + localStory: 'Local runtime and queue-trigger tests', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'preview-resources.ts', + 'queue.ts', + 'case6/*' + ], + overview: { + readTime: '4 min read', + title: 'Use Queues when work should happen later, in batches, or with retries', + summary: + 'Devflare models Queue producers and consumers explicitly, which makes local tests and preview naming much easier to reason about.', + description: + 'The config shape keeps the relationship visible: which bindings can enqueue work, which consumer handles that queue, and how retries or dead-letter behavior should look.', + highlights: [ + 'Producers and consumers are modeled in one consistent `bindings.queues` shape.', + 'Compile turns that into Wrangler producer and consumer entries.', + '`cf.queue.trigger()` makes local queue-consumer tests straightforward.', + 'Preview lifecycle can include queue and DLQ naming when branch-specific infrastructure matters.' + ], + bestFor: 'Background jobs, async processing, fan-out work, and controlled retry behavior', + authoringParagraphs: [ + 'Queues are easiest to understand when the producer names and consumer config live together in the same authored source of truth.', + 'That way the code review already shows who sends messages, who processes them, and where failures go when retries run out.' + ], + authoringSnippet: { + title: 'Queue producer and consumer authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-worker', + bindings: { + queues: { + producers: { + JOBS: 'jobs-queue' + }, + consumers: [ + { + queue: 'jobs-queue', + deadLetterQueue: 'jobs-dlq', + maxRetries: 3 + } + ] + } + } +})` + }, + fitBullets: [ + 'Use Queues when the worker should hand work off instead of blocking the original request.', + 'They are a good fit for batch processing, notifications, post-request writes, and work that deserves retry control.', + 'If the task must happen synchronously in the request path, a queue is probably the wrong tool.' + ], + caveatBullets: [ + 'Keep producer and consumer intent explicit so dead-letter and retry behavior is reviewable.', + 'Preview-scoped queues and DLQs are possible, but they should be created only when the preview really owns separate async infrastructure.', + 'Queue tests should separate handler behavior from wider route or scheduling concerns.' + ], + caveatCallout: { + tone: 'info', + title: 'The queue rule of thumb', + body: [ + 'If a request can safely say โ€œI accepted the workโ€ before the work is complete, queues are a good candidate. If not, keep it in the request path.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'Queue config is compiled into explicit producer and consumer blocks, with preview resource materialization available for both queue names and DLQs.', + description: + 'This is one of the clearer compiler paths in Devflare: producers become env bindings, consumers become worker-side queue listeners, and preview lifecycle code can materialize names when the preview should own separate queues.', + highlights: [ + 'Compiler emits Wrangler `queues.producers` and `queues.consumers`.', + 'Consumer options like retries and concurrency are converted into the output shape Wrangler expects.', + 'Preview resource logic can materialize queue names and dead-letter queues.', + 'Local queue triggers fit naturally into the Devflare test harness.' + ], + normalizationFact: + 'Producer and consumer config is split into one normalized queue model before compile', + compileTarget: 'Wrangler `queues.producers` and `queues.consumers`', + previewNote: + 'Preview queue names and DLQs can be provisioned and cleaned up when the preview owns them', + normalizationParagraphs: [ + 'Devflare does not treat queue producers and queue consumers as unrelated configuration fragments. It keeps them in one coherent config namespace so later compile and preview code can see the whole story.', + 'Review and runtime stay aligned: the config already names the queue, the producer binding, the consumer, and the dead-letter relationship in one place.' + ], + localRuntimeBullets: [ + 'The local harness can stand up queue producers as real env bindings and trigger the queue handler through test helpers.', + 'Queue helper behavior is different from plain worker fetch behavior because `cf.queue.trigger()` waits for queued background work before returning.', + 'That makes queue tests a good place to assert post-processing side effects directly.' + ], + compileBullets: [ + 'Compile converts consumer options into the output shape Wrangler expects, including retry and dead-letter fields.', + 'Preview materialization can generate branch-specific queue and DLQ names when the preview environment should own separate async infrastructure.', + 'This lifecycle support covers queue resources more directly than service bindings, which mostly stay name-based references.' + ], + callout: { + tone: 'success', + title: 'Queues stay reviewable when the config stays explicit', + body: [ + 'The combination of producers, consumers, and dead-letter settings is much easier to trust when it lives in one visible authored shape.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Queue testing is one of the places where Devflareโ€™s helper surface feels especially good because the queue trigger already knows how to drive the real handler shape.', + description: + 'That means you can test a queue consumer without bootstrapping your own fake message batch or pretending the queue handler is just a random function.', + highlights: [ + '`cf.queue.trigger()` is the normal first tool for queue-consumer tests.', + 'Queue triggers wait for background work before they return.', + 'Producer-side tests can still use the real binding through `env.JOBS.send(...)`.', + 'Use higher-level worker tests only when queueing is part of a larger route behavior.' + ], + bestFor: 'Queue consumer behavior, retries, and queue-driven side effects', + defaultHarness: '`createTestContext()` plus `cf.queue.trigger()`', + escalation: 'You need to verify preview queue lifecycle or deployment topology', + paragraphs: [ + 'Start by triggering the consumer directly. That is usually the shortest path to proving retries, acknowledgements, and side effects like KV writes or database updates.', + 'If the queue is reached from an HTTP route, keep one route-level test too so the enqueue step itself stays visible.' + ], + mainSnippet: { + title: 'Testing a queue consumer through Devflare helpers', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('queue consumer stores a processed result', async () => { + await cf.queue.trigger([ + { + id: 'job-1', + body: { id: 'task-1', type: 'process', createdAt: Date.now() } + } + ]) + + expect(await env.RESULTS.get('result:task-1')).not.toBeNull() +})` + }, + helperBullets: [ + 'Use `cf.queue.trigger()` when the consumer behavior is what you care about.', + 'Use `env.JOBS.send()` when you want to prove enqueue code in the same runtime path.', + 'Queue tests are a good place to assert retries or DLQ behavior because the helper already understands the message shape.' + ], + caveatBullets: [ + 'Queue helper success does not automatically prove your preview or deploy queue topology is right.', + 'If the route-to-queue path matters, keep one request test so the enqueue boundary stays visible.', + 'Batch semantics and failure handling deserve their own tests instead of one giant everything-at-once assertion.' + ], + callout: { + tone: 'accent', + title: 'Queue tests are allowed to be direct', + body: [ + 'You do not need to sneak queue behavior behind HTTP if the queue consumer itself is the thing you want confidence in.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This starter example wires one producer, one consumer, and one stored result so you can see the whole queue loop without ceremony.', + description: + 'A good queue example should prove three things quickly: the request can enqueue work, the consumer can process it, and some visible side effect confirms the work ran.', + highlights: [ + 'One producer plus one consumer is enough to learn the shape.', + 'The side effect should be visible and cheap to assert.', + 'Retries belong in tests once the happy path is working.', + 'This shape scales naturally into larger background pipelines later.' + ], + configFocus: 'Explicit producer and consumer config', + runtimeShape: 'Request enqueues work, queue handler stores result', + bestUse: 'Background jobs and post-request processing', + configSnippet: { + title: 'Minimal queue config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-example', + bindings: { + kv: { + RESULTS: 'results-kv' + }, + queues: { + producers: { + JOBS: 'jobs-queue' + }, + consumers: [ + { + queue: 'jobs-queue' + } + ] + } + } +})` + }, + usageSnippet: { + title: 'One fetch path and one queue consumer', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' +import type { MessageBatch } from '@cloudflare/workers-types' + +export async function fetch(): Promise { + await env.JOBS.send({ id: 'job-1', createdAt: Date.now() }) + return new Response('queued', { status: 202 }) +} + +export async function queue(batch: MessageBatch<{ id: string }>): Promise { + for (const message of batch.messages) { + await env.RESULTS.put('job:' + message.body.id, 'done') + message.ack() + } +}` + }, + notes: [ + 'Once this shape works, you can add retries, DLQs, and richer payloads without changing the fundamental loop.', + 'This example stays intentionally small so the queue contract is the thing you notice first.' + ], + callout: { + tone: 'info', + title: 'Keep the first side effect visible', + body: [ + 'Writing one result record is a better first example than a complex job pipeline you cannot see end to end.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-3.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-3.ts new file mode 100644 index 0000000..5f77876 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-3.ts @@ -0,0 +1,448 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart3: BindingGuideDefinition[] = [ + { + slugBase: 'service', + label: 'Services', + categoryDescription: + 'Worker-to-worker bindings with `ref()` support, typed env generation, and good local multi-worker tests.', + configKey: 'bindings.services', + authoringShape: + 'Record | ref().worker(...)', + localStory: 'Local runtime and multi-worker tests', + sourcePages: [ + 'schema-bindings.ts', + 'ref.ts', + 'resolve-service-bindings.ts', + 'generator.ts', + 'case5/*' + ], + overview: { + readTime: '4 min read', + title: 'Use service bindings to keep multi-worker apps explicit instead of magical', + summary: + 'The fast Devflare payoff is simple: wire one worker to another with `ref()`, call it through `env.MATH_SERVICE`, and prove the same relationship locally in one test.', + description: + 'This is the clean lane for apps that genuinely need more than one worker. Devflare keeps the worker family explicit in config, resolves the referenced surface, and lets local tests use the same service binding contract instead of copied worker names or hand-built internal URLs.', + highlights: [ + '`ref()` keeps worker relationships explicit instead of hiding them in env vars or copied script names.', + 'Gateway code calls the service through the same `env.MATH_SERVICE` contract the tests use.', + 'Local multi-worker tests work through the default harness instead of custom setup glue.', + '`devflare types` can generate typed service bindings and fall back to `Fetcher` when a service cannot be typed.' + ], + bestFor: 'Multi-worker systems, internal RPC boundaries, and explicit service composition', + authoringParagraphs: [ + 'The easiest honest starting point is one gateway worker, one referenced worker, and one service binding in config.', + '`ref()` is especially useful because it keeps the dependency explicit while still giving Devflare enough structure to resolve, type, and boot the linked worker locally later.' + ], + authoringSnippet: { + title: 'Service binding authoring with `ref()`', + language: 'ts', + code: String.raw`import { defineConfig, ref } from 'devflare/config' + +const mathService = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + bindings: { + services: { + MATH_SERVICE: mathService.worker, + ADMIN: mathService.worker('AdminEntrypoint') + } + } +})` + }, + fitBullets: [ + 'Use service bindings when another worker is a real dependency, not when one large worker is merely inconvenient to think about.', + 'They are a strong fit for internal APIs, admin surfaces, search workers, and explicit worker-family boundaries.', + 'If the dependency is actually shared data rather than another service boundary, a direct binding like D1, KV, or DO may stay simpler.' + ], + caveatBullets: [ + 'Preview isolation follows resolved worker names, not just whatever branch or alias string you passed to a deploy command.', + 'Named entrypoints are modeled, but critical production wiring is still worth validating in compiled output.', + 'Service bindings are references, not preview-managed account resources like KV, D1, or queues.' + ], + caveatCallout: { + tone: 'info', + title: 'A very good review question', + body: [ + 'Ask which worker names a preview will actually deploy before you assume the worker family is isolated.' + ] + } + }, + internals: { + readTime: '4 min read', + summary: + 'Devflare resolves referenced worker configs, bundles the linked worker surfaces, and then exposes those services as local multi-worker bindings.', + description: + 'Service bindings feel more than cosmetic: the tooling follows the relationship far enough to keep local tests, type generation, and compiled output aligned.', + highlights: [ + 'Compiler emits Wrangler `services` entries.', + '`ref()` can resolve both default worker exports and named entrypoints.', + 'Local multi-worker setup uses generated service binding metadata, not lucky guesses.', + 'Type generation can map service bindings to real interfaces when Devflare knows enough about the target.' + ], + normalizationFact: + 'Plain objects and `ref().worker(...)` values normalize into one service-binding model', + compileTarget: 'Wrangler `services`', + previewNote: + 'Preview can rewrite service names, but service bindings are not preview-managed resources like KV or D1', + normalizationParagraphs: [ + 'Service bindings can be authored as plain binding objects or as `ref().worker(...)` results. Devflare normalizes those into one shape so compiler, type generation, and test setup can all reason about them consistently.', + 'When a binding comes from `ref()`, Devflare can follow the referenced config, discover the relevant worker surface, and keep that relationship visible in local tooling.' + ], + localRuntimeBullets: [ + '`resolveServiceBindings()` is responsible for following referenced configs and bundling the default `worker.ts` export or named entrypoints as needed.', + 'Local multi-worker Miniflare wiring uses the resolved service metadata so a gateway worker can call another worker naturally in tests.', + 'Type generation can emit service-specific interfaces; if that is not possible, the binding falls back to a generic `Fetcher` contract.' + ], + compileBullets: [ + 'Compile emits the standard `services` array that Wrangler expects.', + 'Preview flows can rewrite service names when the preview naming rules say they should, but there is no separate resource-provisioning lifecycle for services themselves.', + 'Critical production wiring is still worth checking through `config print`, `build`, or dry-run deploy output.' + ], + callout: { + tone: 'success', + title: 'This is configuration as architecture, not just syntax', + body: [ + 'Service bindings work well in Devflare because the relationships are explicit enough for tooling to follow, type, and test.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Service bindings are one of the clearest Devflare wins in multi-worker apps: you can keep the real worker boundary and still prove it through the default local harness.', + description: + 'Start with `createTestContext()`, then call the bound service through the generated env shape. That proves the config relationship, the local worker family, and the callable contract in the same language the app itself uses.', + highlights: [ + '`createTestContext()` can auto-detect service bindings from config.', + 'One direct env call is usually enough to prove the wiring honestly.', + 'The default and named entrypoint stories are both testable through the env.', + 'Generated env types make service calls much easier to trust.', + 'You only need higher-level deploy checks when naming or preview topology is the real risk.' + ], + bestFor: 'Gateway-to-service calls, entrypoint wiring, and typed multi-worker behavior', + defaultHarness: '`createTestContext()` plus `env.MY_SERVICE`', + escalation: + 'The risk is worker naming drift, preview topology, or compiled output correctness', + paragraphs: [ + 'The shortest honest test is usually one real service call through the generated env binding. That already proves the config relationship and the callable surface.', + 'Keep one test for the default worker entry and one for any named entrypoint that matters operationally.' + ], + mainSnippet: { + title: 'Testing a service binding through the env', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('service binding calls the default worker export', async () => { + expect(await env.MATH_SERVICE.add(5, 3)).toBe(8) +})` + }, + helperBullets: [ + 'Use the bound env service directly when the service relationship is the thing you want to prove.', + 'Keep named entrypoints explicit in tests so they do not quietly drift from the config contract.', + 'Run `devflare types` whenever service entrypoints change so env autocomplete and generated types stay in sync.' + ], + caveatBullets: [ + 'Local tests prove the callable relationship, not that your preview or production worker names are what you intended.', + 'If the service graph is business-critical, validate compiled output before deploys as well.', + 'Test naming and topology at preview or build time when those are the real failure modes.' + ], + callout: { + tone: 'warning', + title: 'A typed local call is not the whole deploy story', + body: [ + 'The local harness tells you the relationship is modeled correctly. A preview or build check tells you the resolved worker names are still the ones you expect.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example shows the smallest useful service-binding loop: one `ref()`, one gateway route, and one local multi-worker test.', + description: + 'That is enough to show why Devflare helps here: the relationship stays explicit in config, typed in env, and testable without hand-assembling your own mini service mesh in the test file.', + highlights: [ + 'One worker calling another is enough to learn the pattern.', + '`ref()` keeps the dependency visible.', + 'The env binding is the public contract the gateway uses.', + 'You can grow into named entrypoints later without changing the mental model.' + ], + configFocus: 'Explicit `ref()` wiring', + runtimeShape: 'One env service call from the gateway worker', + bestUse: 'Internal APIs and worker-family boundaries', + configSnippet: { + title: 'Gateway config with a service ref', + language: 'ts', + code: String.raw`import { defineConfig, ref } from 'devflare/config' + +const mathService = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + MATH_SERVICE: mathService.worker + } + } +})` + }, + usageSnippet: { + title: 'Use the service in the gateway worker', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.MATH_SERVICE.add(4, 5) + return Response.json({ result }) +}` + }, + notes: [ + 'Once this tiny path works, adding named entrypoints becomes an incremental extension, not a different architecture.', + 'Keep one simple service example like this around if you want a smoke check for multi-worker wiring.' + ], + callout: { + tone: 'accent', + title: 'This is the valuable bit', + body: [ + 'You do not need a whole microservice fleet to feel the Devflare value. One gateway call already proves that config refs, env bindings, and local multi-worker tests stay part of one coherent loop.' + ] + } + } + }, + { + slugBase: 'ai', + label: 'AI', + categoryDescription: + 'Workers AI bindings for remote inference, with a deliberately remote-oriented testing story.', + configKey: 'bindings.ai', + authoringShape: '{ binding: string }', + localStory: 'Remote-oriented; local tests require remote mode', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'wrangler-auth.ts', + 'remote-ai.ts', + 'packages/devflare/src/test/simple-context.ts' + ], + overview: { + readTime: '4 min read', + title: + 'Use the AI binding when the worker needs real Workers AI inference, not just a local mock', + summary: + 'Devflare makes Workers AI usable by keeping the binding tiny in config, the worker call obvious, and the remote smoke test explicit instead of fake.', + description: + 'AI is still remote-oriented, but the first useful path is simple: one worker route, one `env.AI.run(...)` call, and one skip-aware remote test that says clearly when the real platform was involved.', + highlights: [ + 'Config is intentionally tiny: declare the binding name and keep the interesting part in worker code.', + 'Compiler emits the Wrangler AI binding shape directly.', + '`shouldSkip.ai` and remote mode let tests say exactly when they exercised real inference.', + 'Local-only app work can still stub above the worker boundary without lying about the binding path.' + ], + bestFor: 'Real inference against Workers AI models', + authoringParagraphs: [ + 'AI is a remote-oriented binding, but the first worker path should still be tiny and concrete: receive one request, call one model, return one JSON response.', + 'The Devflare-specific win is not fake local inference. It is that config, worker code, and remote test gating stay explicit enough that you know when the real platform was actually exercised.' + ], + authoringSnippet: { + title: 'Workers AI binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'ai-worker', + bindings: { + ai: { + binding: 'AI' + } + } +})` + }, + fitBullets: [ + 'Use AI when the worker should call a real Workers AI model.', + 'Keep the binding dedicated to model work instead of pretending every route needs AI by default.', + 'If the only goal is local happy-path UI wiring, use a normal fake at the app edge and reserve remote AI tests for the worker boundary.' + ], + caveatBullets: [ + 'AI is remote-oriented, so local-only test runs should not be expected to exercise real inference.', + 'Cloudflare auth and a resolvable account are part of the contract for meaningful AI tests. An explicit `accountId` helps when the target account would otherwise be ambiguous, but it is not the only way Devflare can resolve one.', + 'Because inference has cost and availability implications, it deserves more deliberate test gating than local-first bindings.' + ], + caveatCallout: { + tone: 'warning', + title: 'Do not present AI as a local-first binding', + body: [ + 'The honest story is that Devflare supports the binding cleanly, but real AI behavior still requires remote infrastructure.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'AI has a smaller compiler story than storage bindings, but a more explicit auth and remote-runtime story.', + description: + 'Devflare does not invent a fake local AI runtime. It compiles the binding, checks remote requirements when needed, and exposes remote helpers for tests that intentionally opt in.', + highlights: [ + 'Compiler emits the Wrangler AI binding directly.', + 'Auth checks treat AI as a remote binding with real account requirements.', + '`createTestContext()` can inject a remote AI binding when remote mode is enabled.', + 'Test skip helpers exist specifically because AI is not a universal local path.' + ], + normalizationFact: + 'The authored shape is small, so the important complexity lives in auth and remote enablement rather than config normalization', + compileTarget: 'Wrangler `ai` binding', + previewNote: + 'AI is remote-oriented; preview is less about provisioning and more about whether the worker path may call the model', + normalizationParagraphs: [ + 'AI does not need the same name-versus-id resolution dance as KV or D1. The authored shape is basically โ€œwhich env binding name should exist.โ€', + 'The heavier implementation work lives in auth checks and remote-test setup, because the value of the binding only appears once the worker can reach real Cloudflare AI services.' + ], + localRuntimeBullets: [ + '`checkRemoteBindingRequirements()` treats AI as a binding that requires remote account context.', + '`createTestContext()` can inject a remote AI helper when remote mode is enabled and an account can be resolved.', + '`Ai.gateway()` is not supported by the current remote AI test helper, so gateway-specific flows need a higher-level integration path.', + '`shouldSkip.ai` exists so tests can say clearly when remote inference is unavailable instead of failing opaquely.' + ], + compileBullets: [ + 'Compile emits the AI binding shape directly into generated Wrangler output.', + 'Because the runtime behavior is remote-oriented, the major operational risk is not syntax โ€” it is auth, availability, and cost control.', + 'Preview behavior is mostly about whether that worker path should call real models, not about separate preview-managed AI resources.' + ], + callout: { + tone: 'info', + title: 'Honest tooling beats fake local magic', + body: [ + 'Devflare makes AI explicit and testable, but it does not pretend local emulation is equivalent to real inference.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'The right AI test strategy is selective: use remote mode when you mean to test inference, and skip cleanly when the environment is not allowed to do that.', + description: + 'Trying to force AI into the same local-only expectations as KV or D1 leads to misleading tests. Devflare already gives you the right gates โ€” use them.', + highlights: [ + 'AI tests are usually remote-mode integration tests with explicit opt-in.', + '`shouldSkip.ai` is the intended guard for unsupported or unauthenticated environments.', + 'Keep prompts and assertions small so the test verifies the binding contract, not a giant product behavior.', + 'Local-only flows should stub above the worker boundary rather than pretending AI itself was tested.' + ], + bestFor: 'Remote inference checks and binding-level AI smoke tests', + defaultHarness: '`createTestContext()` after remote mode is enabled, plus `shouldSkip.ai`', + escalation: + 'The AI call is expensive, flaky, or business-critical enough to need a separate release gate', + paragraphs: [ + 'Start with a tiny inference call and a tiny assertion. The goal is to prove that the binding works and the worker can talk to the intended model, not to test your entire AI product in one unit test.', + 'Enable remote mode first โ€” for example with `devflare remote enable ...` or `DEVFLARE_REMOTE=1` (or another truthy value) in automation โ€” and skip explicitly when the environment still cannot support remote AI instead of forcing the test to fail in noisy ways.' + ], + mainSnippet: { + title: 'A remote-oriented AI test', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +const skipAI = await shouldSkip.ai + +describe.skipIf(skipAI)('AI binding', () => { + test('runs a tiny inference request', async () => { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply with OK only.' }], + max_tokens: 4 + }) + + expect(result).toBeDefined() + }) +})` + }, + helperBullets: [ + 'Enable remote mode before expecting `createTestContext()` to inject a real AI binding, for example with `DEVFLARE_REMOTE=1` in automation.', + 'Use `shouldSkip.ai` to make remote prerequisites explicit in the test file itself.', + 'Keep AI assertions small enough that failures teach you about the binding path, not about prompt engineering drift.', + 'Use non-AI stubs above the worker layer when the app UI only needs a placeholder during purely local development.' + ], + caveatBullets: [ + 'Remote AI tests are not free; keep them targeted and intentional.', + 'If the worker depends on `Ai.gateway()`, test that path outside the remote AI helper because the helper warns and does not implement gateway mode.', + 'If the worker contract is business-critical, move AI smoke tests into an explicit integration or release lane rather than running them everywhere.', + 'Do not confuse local app mocks with proof that the real AI binding path works.' + ], + callout: { + tone: 'accent', + title: 'Skip is not weakness here', + body: [ + 'For remote bindings, a clear skip condition is often more trustworthy than a forced local pseudo-test that never exercised the real platform.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example keeps the AI story honest and useful: one binding, one tiny inference route, and one skip-aware remote smoke test.', + description: + 'That is enough to show the Devflare value: config stays tiny, the worker code stays normal, and the test tells you clearly when remote AI was really available.', + highlights: [ + 'One model call is enough to show the shape.', + 'The example stays focused on the worker boundary, not app-level chat UX.', + 'The smoke test uses Devflareโ€™s remote gate instead of pretending inference is local.', + 'You can stub above this route in local UI work without changing the worker contract.' + ], + configFocus: 'Minimal binding declaration', + runtimeShape: 'Call `env.AI.run(...)` from the worker', + bestUse: 'Small inference endpoints and smoke checks', + configSnippet: { + title: 'Minimal AI config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'ai-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + ai: { + binding: 'AI' + } + } +})` + }, + usageSnippet: { + title: 'A tiny inference endpoint', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply with OK only.' }], + max_tokens: 4 + }) + + return Response.json({ result }) +}` + }, + notes: [ + 'Use a cheap, small model in smoke paths unless the point is to verify a specific expensive production model.', + 'Keep local app mocks above this worker route if you need offline UI development.' + ], + callout: { + tone: 'accent', + title: 'The Devflare win is the explicit remote gate', + body: [ + 'A clear skip condition is more trustworthy than a fake local AI emulator that never touched the real platform. That honesty is part of what makes the Devflare AI story usable.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-4.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-4.ts new file mode 100644 index 0000000..10b83f9 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-4.ts @@ -0,0 +1,469 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart4: BindingGuideDefinition[] = [ + { + slugBase: 'vectorize', + label: 'Vectorize', + categoryDescription: + 'Vector similarity indexes with explicit remote testing and preview-aware index naming.', + configKey: 'bindings.vectorize', + authoringShape: 'Record', + localStory: 'Remote-oriented; local tests require remote mode or explicit mocks', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'preview-resources.ts', + 'remote-vectorize.ts', + 'case15/*' + ], + overview: { + readTime: '4 min read', + title: + 'Use Vectorize when the worker really owns similarity search, not just string matching', + summary: + 'Devflare makes Vectorize usable by keeping the index name explicit in config, preview naming honest, and the real smoke test explicit instead of buried under mocks.', + description: + 'The right first path is small: one binding, one tiny upsert-and-query route, and one skip-aware remote smoke test that tells the truth about whether the real index was involved.', + highlights: [ + 'Each binding declares an explicit `indexName`.', + 'Compile emits Wrangler `vectorize` entries.', + 'Preview-scoped Vectorize indexes are part of Devflareโ€™s resource lifecycle story.', + '`shouldSkip.vectorize` makes the remote test contract obvious instead of noisy.' + ], + bestFor: + 'Similarity search, embedding-backed lookup, and retrieval paths that belong in the worker', + authoringParagraphs: [ + 'Vectorize authoring is simple in config, but the operational story matters: an index must exist, dimensions must match, and tests should acknowledge that they are calling a real remote system.', + 'Devflare helps by keeping the binding explicit, the index name visible, and preview resource handling deliberate when the preview needs its own index.' + ], + authoringSnippet: { + title: 'Vectorize binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + vectorize: { + DOCUMENT_INDEX: { + indexName: 'document-index' + } + } + } +})` + }, + fitBullets: [ + 'Use Vectorize when semantic similarity is part of the workerโ€™s real job, not when plain text search is already enough.', + 'It fits best when the worker is already producing or consuming embeddings as part of the application flow.', + 'If the vector store is optional or external to the worker, keep the boundary explicit and do not force Vectorize into every local path.' + ], + caveatBullets: [ + 'Real Vectorize tests need remote access and an index that actually exists.', + 'Preview-scoped indexes are possible and lifecycle-managed, but they should be created only when the preview really needs isolated vector state.', + 'Local fake vector stores can be useful above the worker boundary, but they are not proof that the real binding path works.' + ], + caveatCallout: { + tone: 'warning', + title: 'Dimension and index setup are part of the contract', + body: [ + 'A passing unit test with a fake array is not equivalent to a real Vectorize call against the configured index.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'Vectorize compiles cleanly into Wrangler output and participates in preview resource lifecycle, but the runtime value of the binding mostly lives in remote infrastructure.', + description: + 'The codebase treats Vectorize as supported but remote-oriented. Config and preview handling are strong; local emulation is intentionally not oversold.', + highlights: [ + 'Compile emits `vectorize` entries with `index_name`.', + 'Preview resource logic can provision and later clean up preview-scoped indexes.', + '`createTestContext()` can inject remote Vectorize helpers when remote mode is enabled.', + '`shouldSkip.vectorize` exists because real remote prerequisites are part of the contract.' + ], + normalizationFact: + 'The authored shape is small, so most complexity is in remote access and preview resource lifecycle', + compileTarget: 'Wrangler `vectorize`', + previewNote: 'Preview-scoped Vectorize indexes are lifecycle-managed resources in Devflare', + normalizationParagraphs: [ + 'Each Vectorize binding is a named env entry pointing to an explicit `indexName`. There is not much normalization complexity because the important value is already visible in source.', + 'The heavier internal story is around preview resource handling and remote test support, because that is where real index existence and lifecycle start to matter.' + ], + localRuntimeBullets: [ + '`createTestContext()` can supply a remote Vectorize binding when remote mode is enabled.', + 'The codebase uses `shouldSkip.vectorize` to make missing remote prerequisites explicit in tests.', + 'The exhaustive smoke app also uses mocks for some integration checks, which is fine as long as the docs do not confuse that with full local emulation.' + ], + compileBullets: [ + 'Compile emits `index_name` into generated Wrangler-facing config.', + 'Preview resource lifecycle code can materialize branch-specific index names and later clean them up.', + 'Because the binding is remote-oriented, the hardest failures are usually missing indexes, dimension mismatches, or auth โ€” not config syntax.' + ], + callout: { + tone: 'info', + title: 'Supported does not mean locally emulated', + body: [ + 'Vectorize is fully part of the config schema and preview story, but the meaningful runtime path still belongs to the remote platform.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'The right Vectorize tests are targeted remote checks: a small insert or query, a clear skip condition, and a real index behind the binding.', + description: + 'Avoid pretending a local fake embedding store proved the same thing. It may still be useful for UI or higher-level app tests, but it is not the binding test.', + highlights: [ + 'Use remote mode plus `shouldSkip.vectorize` for truthful binding tests.', + 'Keep the vector dimensions and index name explicit in the test setup.', + 'Small insert/query flows are enough for a smoke test.', + 'Local mocks are fine higher up the stack, just not as evidence that the binding itself works.' + ], + bestFor: 'Remote similarity-search checks and index smoke tests', + defaultHarness: '`createTestContext()` in remote mode plus `shouldSkip.vectorize`', + escalation: + 'The index contract is business-critical enough to need explicit CI or release gating', + paragraphs: [ + 'Keep the test as small as possible: insert one vector or query one known embedding and verify the shape of the result.', + 'If the index is missing, skip with a clear message. That teaches future maintainers more than a mysterious failure ever will.' + ], + mainSnippet: { + title: 'A remote Vectorize smoke test', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +const skipVectorize = await shouldSkip.vectorize + +describe.skipIf(skipVectorize)('Vectorize binding', () => { + test('accepts one upsert and one query', async () => { + const vector = Array(32).fill(0.5) + await env.DOCUMENT_INDEX.upsert?.([ + { id: 'doc-1', values: vector, metadata: { kind: 'demo' } } + ]) + + const result = await env.DOCUMENT_INDEX.query?.(vector, { topK: 1 }) + expect(result).toBeDefined() + }) +})` + }, + helperBullets: [ + 'Use `shouldSkip.vectorize` so missing remote prerequisites are explicit instead of noisy.', + 'Keep the vector size and index name close to the test so the contract remains visible.', + 'If the surrounding app only needs a demo path locally, mock above the worker boundary instead of pretending the remote index was exercised.' + ], + caveatBullets: [ + 'Running Vectorize tests everywhere is rarely necessary; put them where the signal is worth the cost.', + 'A passing local mock tells you nothing about index existence or vector dimension compatibility.', + 'If a preview environment owns its own index, add one lifecycle-aware check for that path specifically.' + ], + callout: { + tone: 'accent', + title: 'A tiny real query beats a giant fake suite', + body: [ + 'For remote vector search, one truthful remote smoke check is often worth more than a dozen intricate local fakes.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example keeps Vectorize honest and usable: one index binding, one upsert-and-query route, and one skip-aware remote smoke test.', + description: + 'That is enough to show the binding shape, the worker contract, and the Devflare remote gate without dragging in a whole retrieval stack on page one.', + highlights: [ + 'The index name stays explicit in config.', + 'The runtime path shows both write and read shape.', + 'The remote smoke test uses Devflareโ€™s skip gate instead of pretending similarity search is local.', + 'The worker contract remains visible even if the app wraps it elsewhere.' + ], + configFocus: 'Explicit index naming', + runtimeShape: 'Upsert one vector and query it back', + bestUse: 'Search prototypes and embedding-backed retrieval endpoints', + configSnippet: { + title: 'Minimal Vectorize config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'vectorize-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + vectorize: { + DOCUMENT_INDEX: { + indexName: 'document-index' + } + } + } +})` + }, + usageSnippet: { + title: 'A tiny write-and-query route', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const vector = Array(32).fill(0.5) + + await env.DOCUMENT_INDEX.upsert?.([ + { id: 'doc-1', values: vector, metadata: { title: 'Demo doc' } } + ]) + + const result = await env.DOCUMENT_INDEX.query?.(vector, { + topK: 1, + returnMetadata: true + }) + + return Response.json({ result }) +}` + }, + notes: [ + 'Keep the embedding dimension explicit and consistent with the actual index you created.', + 'If you later split write and read into separate routes, this same example still teaches the core binding path.' + ], + callout: { + tone: 'accent', + title: 'The Devflare win is honest lifecycle plus honest gating', + body: [ + 'The named index still has to exist, but Devflare keeps that reality visible in config, preview naming, and skip-aware tests instead of hiding it behind fake local success.' + ] + } + } + }, + { + slugBase: 'hyperdrive', + label: 'Hyperdrive', + categoryDescription: + 'PostgreSQL-oriented bindings with schema support, name resolution, and local connection strings for Miniflare.', + configKey: 'bindings.hyperdrive', + authoringShape: + 'Record', + localStory: + 'Full local support when Devflare has a local database connection string for the binding', + sourcePages: [ + 'schema-bindings.ts', + 'schema-normalization.ts', + 'resource-resolution.ts', + 'preview-resources.ts', + 'case14/*' + ], + overview: { + readTime: '4 min read', + title: + 'Use Hyperdrive when the worker needs a real PostgreSQL path behind Cloudflareโ€™s pooling layer', + summary: + 'Hyperdrive is modeled in Devflare config, compile flows, local Miniflare wiring, and pure tests through explicit local connection strings.', + description: + 'For local work, point the binding at a local or test PostgreSQL database. Cloudflare still owns the hosted pooling layer, placement, account credentials, and production routing.', + highlights: [ + 'String shorthand means a stable Hyperdrive configuration name.', + 'Build and deploy can resolve names to Hyperdrive ids.', + 'Local dev and tests can use `localConnectionString` or the Hyperdrive local connection env var.', + 'Preview handling is special because Hyperdrive configs cannot always be cloned automatically.' + ], + bestFor: 'Workers that connect to PostgreSQL through Hyperdrive', + authoringParagraphs: [ + 'Hyperdrive follows the same stable-name instinct as KV and D1: author a readable name in source when you can, then let Devflare resolve ids later when a flow actually needs them.', + 'Add `localConnectionString` when local dev or tests should query a local database without contacting Cloudflare.' + ], + authoringSnippet: { + title: 'Hyperdrive binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'postgres-worker', + bindings: { + hyperdrive: { + DB: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + ANALYTICS_DB: { id: 'hyperdrive-id' } + } + } +})` + }, + fitBullets: [ + 'Use Hyperdrive when the worker needs PostgreSQL and you want the Cloudflare-managed connection path rather than raw direct wiring.', + 'It fits best when a real Postgres database already exists and the worker boundary should speak to it deliberately.', + 'If your data is already a comfortable fit for D1, D1 may still be the simpler first choice.' + ], + caveatBullets: [ + 'Use `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_DB` to override the configured local connection string in CI or per-developer shells.', + 'Preview-scoped Hyperdrive configs are not auto-cloned from the base configuration because stored credentials are not always available for that.', + 'When a preview Hyperdrive config does not exist, Devflare may fall back to the base configuration and warn.' + ], + caveatCallout: { + tone: 'warning', + title: 'Local and hosted responsibilities are different', + body: [ + 'Devflare can wire the local database path. Cloudflare still owns hosted pooling, production credentials, placement, billing, and account state.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'Hyperdrive uses the same normalize-and-resolve pattern as KV and D1, and local runtime config is driven by the binding connection string.', + description: + 'That fallback behavior is worth documenting explicitly because it changes how you should think about preview isolation and cleanup for database-backed flows.', + highlights: [ + 'String shorthand means a stable Hyperdrive configuration name.', + 'Compile emits Wrangler `hyperdrive` entries after resolution.', + 'Local Miniflare config receives `hyperdrives` only when a local connection string is available.', + 'Cleanup can remove preview Hyperdrives that actually exist, but cloning is not automatic.' + ], + normalizationFact: + 'Hyperdrive follows the same name-versus-id normalization family as KV and D1', + compileTarget: 'Wrangler `hyperdrive`', + previewNote: + 'Preview Hyperdrive configs may fall back to the base config when a preview clone cannot be materialized', + normalizationParagraphs: [ + 'Hyperdrive authoring accepts a string, `{ name }`, or `{ id }`, and Devflare normalizes those into one internal binding shape so later code can treat them consistently.', + 'That part looks familiar if you already understand KV or D1. The unusual part is preview lifecycle, not the authored schema.' + ], + localRuntimeBullets: [ + 'Devflare passes `bindings.hyperdrive.*.localConnectionString` into Miniflare `hyperdrives` so local Worker code can use the normal Hyperdrive binding shape.', + '`CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_` and the legacy `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_` override config for local runs.', + 'Pure tests can use `createOfflineEnv()` or `createMockHyperdrive()` when the application code only needs the connection string.' + ], + compileBullets: [ + 'Build and deploy resolve name-based Hyperdrive bindings to real configuration ids before generating output.', + 'Preview resource logic cannot always clone a base Hyperdrive config because Cloudflare does not expose stored credentials for that workflow.', + 'When a preview Hyperdrive config is missing but the base config exists, Devflare can fall back to the base binding and warn instead of pretending isolation happened.' + ], + callout: { + tone: 'info', + title: 'This is a lifecycle caveat, not a syntax caveat', + body: [ + 'The config shape is straightforward. The reason Hyperdrive needs extra documentation is the preview and credential story, not the authoring syntax.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Hyperdrive testing should start with a local connection string, then add a focused query test against the database shape your app actually uses.', + description: + 'Devflare can provide the binding locally. Your test database still has to exist, just like any other local Postgres dependency.', + highlights: [ + 'Start with a deterministic local connection string.', + 'Prefer targeted integration tests for the real PostgreSQL path.', + 'Keep preview-fallback behavior visible in tests when preview isolation matters.', + 'Use Cloudflare only when hosted pooling, placement, or account lifecycle is the assertion.' + ], + bestFor: 'Local PostgreSQL integration paths and connection-string-driven app code', + defaultHarness: '`createTestContext()` or `createOfflineEnv()` with `localConnectionString`', + escalation: 'The app depends on real preview isolation or actual Postgres query behavior', + paragraphs: [ + 'Start with one small assertion that the binding exposes the local connection string your database client expects.', + 'Then add focused integration tests against the actual local database path instead of involving Cloudflare for application-owned SQL behavior.' + ], + mainSnippet: { + title: 'A conservative Hyperdrive smoke test', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('Hyperdrive binding exposes local connection info', () => { + expect(env.DB).toBeDefined() + expect(env.DB.connectionString).toContain('localhost') +})` + }, + helperBullets: [ + 'Use `localConnectionString` in config when the test should run without Cloudflare.', + 'Use `createMockHyperdrive` when a pure unit test needs a Hyperdrive-shaped binding without Miniflare.', + 'Keep one higher-level integration path for the real database behavior you actually care about.', + 'If preview isolation matters, test the fallback or dedicated preview strategy explicitly.' + ], + caveatBullets: [ + 'Do not run local Hyperdrive tests against a shared production database.', + 'If the worker truly depends on live query behavior, prefer an integration test against a real database path.', + 'Preview-specific Hyperdrive expectations deserve a dedicated test because automatic cloning is not guaranteed.' + ], + callout: { + tone: 'warning', + title: 'Keep database ownership explicit', + body: [ + 'Devflare owns the binding wiring; the test suite owns the local database lifecycle and seed data.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example uses Hyperdrive in an application route that reads one product from PostgreSQL.', + description: + 'Use the same route locally with a local Postgres connection string, then deploy with the Cloudflare Hyperdrive configuration id or name.', + highlights: [ + 'The config stays readable through a stable Hyperdrive name.', + 'The runtime example uses a real PostgreSQL client.', + 'The route returns a concrete product record.', + 'The production boundary stays visible next to the local connection string.' + ], + configFocus: 'Stable Hyperdrive naming', + runtimeShape: 'Query through `env.DB.connectionString`', + bestUse: 'Product, order, account, or tenant data stored in PostgreSQL', + configSnippet: { + title: 'Minimal Hyperdrive config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hyperdrive-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + hyperdrive: { + DB: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } +})` + }, + usageSnippet: { + title: 'Read one product through Hyperdrive', + language: 'ts', + code: String.raw`import postgres from 'postgres' +import { env, getFetchEvent } from 'devflare/runtime' + +const sql = postgres(env.DB.connectionString) + +export async function fetch(): Promise { + const event = getFetchEvent() + const slug = new URL(event.request.url).searchParams.get('slug') ?? 'starter-kit' + const [product] = await sql.unsafe( + 'select slug, name, price_cents from products where slug = $1 limit 1', + [slug] + ) + + return product ? Response.json(product) : new Response('not found', { status: 404 }) +}` + }, + notes: [ + 'Install the client with `bun add postgres` and point `localConnectionString` at a local or CI Postgres database.', + 'Use Cloudflare-backed tests when the assertion depends on hosted pooling, placement, credentials, or deployed account behavior.' + ], + callout: { + tone: 'info', + title: 'Use a real local database', + body: [ + 'Hyperdrive local support means Devflare can pass the connection path through the binding. It does not create or seed PostgreSQL for you.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-5.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-5.ts new file mode 100644 index 0000000..f0b8f6c --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-5.ts @@ -0,0 +1,452 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart5: BindingGuideDefinition[] = [ + { + slugBase: 'browser', + label: 'Browser Rendering', + categoryDescription: + 'Headless browser support with an explicit single-binding limit and a stronger dev-server story than test-helper story.', + configKey: 'bindings.browser', + authoringShape: 'Record with exactly one entry', + localStory: + 'Supported, but the strongest story is dev server and integration rather than a dedicated test helper', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'browser-shim/*', + 'dev-server/server.ts', + 'case18/*' + ], + overview: { + readTime: '5 min read', + title: 'Use Browser Rendering when the worker really needs a headless browser path', + summary: + 'Browser Rendering shines in Devflareโ€™s bridge-backed dev story: keep one browser binding, one narrow worker route, and one smoke path that proves launch works.', + description: + 'The platform limit is still real โ€” exactly one browser binding โ€” but Devflare adds the missing local ergonomics through the browser shim, binding worker, and integration-friendly route model.', + highlights: [ + 'Current schema allows exactly one browser binding.', + 'Compile emits the single Wrangler browser binding shape from the named env key.', + 'Devflare ships a browser shim and binding worker to support the local/dev story.', + '`devflare types` currently models the binding as `Fetcher`, so the worker boundary is the thing to test and document.', + 'The best first proof is one narrow route that launches Puppeteer, reads one title, and closes cleanly.', + 'Preview naming exists, but browser bindings are not lifecycle-managed account resources like KV or D1.' + ], + bestFor: 'PDF generation, screenshots, and other worker-side headless browser tasks', + authoringParagraphs: [ + 'Browser Rendering looks a little unusual in config because the current contract is a named map with exactly one entry. The env key matters more than the configured string value that appears beside it.', + 'That is also why generated env typing stays conservative today: `devflare types` can model the binding as `Fetcher`, while the richer browser behavior comes from the dev server shim and browser-aware libraries.', + 'That single-binding constraint is not a Devflare whim. It reflects the current Wrangler and platform support Devflare is choosing to expose accurately.' + ], + authoringSnippet: { + title: 'Browser binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'browser-worker', + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +})` + }, + fitBullets: [ + 'Use Browser Rendering when the worker truly needs a browser โ€” for PDF generation, screenshots, or browser-like page evaluation.', + 'Keep browser usage narrow and explicit because browser work is usually heavier than normal request handling.', + 'If a feature can be expressed as a plain fetch or HTML transform, it probably should be.' + ], + caveatBullets: [ + 'Only one browser binding is currently supported.', + 'The strongest local story lives in dev-server and integration flows, not in a rich browser-specific test helper API.', + 'Preview naming exists, but browser resources are not provisioned or deleted like account-managed storage resources.' + ], + caveatCallout: { + tone: 'warning', + title: 'Exactly one really means one', + body: [ + 'If you configure more than one browser binding, schema validation rejects it because the underlying Wrangler contract only supports one.' + ] + } + }, + internals: { + readTime: '4 min read', + summary: + 'Browser Rendering support in Devflare is more than a config pass-through: the dev server starts a browser shim and a binding worker that line up with Cloudflare and puppeteer expectations.', + description: + 'That implementation detail is why the binding belongs in the docs library even though the test helper surface is narrower. There is real, deliberate runtime support here.', + highlights: [ + 'Schema validates that there is exactly one browser binding name.', + 'Compiler emits `browser: { binding: }` from that single env key.', + 'The browser shim installs and proxies the local browser runtime used in dev flows.', + 'The binding worker exists specifically to satisfy the Worker-facing browser contract expected by `@cloudflare/puppeteer`.' + ], + normalizationFact: + 'The env binding name is the important authoring value, while the configured string is mainly used for naming and preview materialization', + compileTarget: 'Wrangler `browser` binding', + previewNote: + 'Preview can materialize the binding name, but browser resources are not lifecycle-managed account resources', + normalizationParagraphs: [ + 'The browser binding schema accepts a record but then validates that only one key exists. Devflare treats that key as the meaningful env binding name and compiles it into the single `browser.binding` entry Wrangler expects.', + 'Emphasize the env key and the single-binding limit rather than implying the string value behaves like a normal bucket or namespace resource.' + ], + localRuntimeBullets: [ + 'The dev server starts a browser shim that can install Chrome Headless Shell and proxy the Browser Rendering protocol over HTTP and WebSocket.', + 'The binding worker exists so browser libraries like `@cloudflare/puppeteer` can talk to the expected Worker-side contract.', + 'Generated env typing stays conservative here too: the binding currently lands as `Fetcher`, which is another reason to keep the worker-facing browser path narrow and explicit.', + 'This is why browser local support feels more like dev-server infrastructure than like a small `cf.browser.*` helper.' + ], + compileBullets: [ + 'Compile emits the single browser binding from the configured env key.', + 'Preview logic can materialize names, but Devflare does not provision or delete browser โ€œresourcesโ€ because they are not account-managed the same way storage bindings are.', + 'The browser path can also warn about missing local WebSocket support when the environment lacks the `ws` dependency needed for proxying.' + ], + callout: { + tone: 'info', + title: 'Local browser-rendering shim', + body: [ + 'The dev-side endpoint Devflare exposes for `@cloudflare/puppeteer` is the **local browser-rendering shim**. It accepts only loopback browser origins (e.g. `http://127.0.0.1:*`, `http://localhost:*`) plus origin-less tool traffic such as Puppeteer or curl.', + 'This loopback-only posture is the security model of the shim itself โ€” it is devflareโ€™s protected helper endpoint for the local Browser Rendering binding. It is **not** a policy applied to your normal worker routes; user app routes still follow whatever request and CORS rules the worker code itself defines.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Browser tests should usually be integration-flavored: either drive the worker in dev or exercise a thin smoke path that proves the binding can launch and fetch.', + description: + 'That is more truthful than pretending the browser binding has the same helper depth as `cf.queue.trigger()` or `env.DB.prepare()`.', + highlights: [ + 'Prefer integration or dev-server smoke paths for browser-heavy behavior.', + 'A tiny dev-server, preview, or other integration-style smoke request is often enough for a binding smoke test.', + 'Keep heavy browser workflows behind narrow routes or DO methods so they remain testable.', + 'You can still use normal worker tests around those routes even if there is no dedicated browser helper.' + ], + bestFor: 'Launch smoke tests, PDF generation routes, and browser-backed worker endpoints', + defaultHarness: + 'A narrow browser route exercised through the dev server, a preview URL, or another integration-style path', + escalation: 'A real browser workflow is mission-critical or too heavy for ordinary test runs', + paragraphs: [ + 'Keep the worker-side browser entry small enough that one smoke path can prove it launches, opens a page, or returns a generated artifact.', + 'If the real logic is bigger โ€” for example a full PDF renderer DO โ€” write one narrow end-to-end check and keep the rest of the code tested at smaller layers.' + ], + mainSnippet: { + title: 'A tiny dev-server browser smoke check', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' + +const baseUrl = process.env.DEVFLARE_TEST_URL ?? 'http://127.0.0.1:8787' + +test('browser-backed route responds', async () => { + const response = await fetch(new URL('/browser-health', baseUrl)) + expect(response.ok).toBe(true) +})` + }, + helperBullets: [ + 'Prefer one narrow worker route or DO method for browser tasks so the binding path stays testable.', + 'Drive that route through the dev server, a preview URL, or another integration path when browser launch itself is the thing under test.', + 'If you want Bun-only unit tests, stub above the browser boundary instead of expecting `createTestContext()` to create a browser binding for you.', + 'Treat browser local checks as smoke tests unless the app really needs a heavier dedicated lane.' + ], + caveatBullets: [ + 'No dedicated browser helper surface means you should test the worker boundary or integration path instead of reaching for fictional convenience APIs.', + '`createTestContext()` is still useful around surrounding worker code, but it is not a browser-specific helper that automatically populates `env.BROWSER` for you.', + 'Browser workloads are heavier than typical request tests, so they deserve intentional scheduling in CI.', + 'If the route depends on browser proxying or WebSockets, test that path in an environment close to the real dev server.' + ], + callout: { + tone: 'accent', + title: 'Smoke test the launch path, not the whole internet', + body: [ + 'Browser bindings get expensive fast. One honest launch or render smoke path is usually better than an enormous browser suite that nobody trusts.' + ] + } + }, + example: { + readTime: '4 min read', + summary: + 'This example shows the real browser path people actually need: one binding, one title-read route, and one smoke check through the dev server.', + description: + 'It is intentionally smaller than a full PDF pipeline, but it uses the same Devflare idea: a narrow worker route on top of a bridge-backed local browser lane.', + highlights: [ + 'The env binding name is what matters in config.', + 'The runtime example uses `@cloudflare/puppeteer` directly.', + 'The smoke check proves the browser route through the same dev/integration boundary users will rely on.', + 'Browser cleanup is part of the example, not an optional footnote.', + 'This is enough to turn into a PDF or screenshot path later.' + ], + configFocus: 'Single browser binding', + runtimeShape: 'Launch puppeteer with the Worker binding and close it cleanly', + bestUse: 'Small screenshot, title-read, or PDF-generation entrypoints', + configSnippet: { + title: 'Minimal browser config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'browser-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +})` + }, + usageSnippet: { + title: 'Read one page title with Puppeteer', + language: 'ts', + code: String.raw`import puppeteer from '@cloudflare/puppeteer' +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const browser = await puppeteer.launch(env.BROWSER as Parameters[0]) + + try { + const page = await browser.newPage() + await page.goto('https://example.com/', { waitUntil: 'load' }) + return Response.json({ title: await page.title() }) + } finally { + await browser.close() + } +}` + }, + notes: [ + 'Keep the first route tiny so launch, navigation, and cleanup are the only moving parts you have to trust.', + 'If the real feature is PDF generation, this same pattern is the foundation for that worker path.' + ], + callout: { + tone: 'accent', + title: 'The Devflare value is the bridge-backed local lane', + body: [ + 'Browser work is still heavier than most bindings, but Devflare gives it a real local/dev story instead of forcing you to document only the production path. Keep the first route narrow enough that launch failures are easy to diagnose.' + ] + } + } + }, + { + slugBase: 'analytics-engine', + label: 'Analytics Engine', + categoryDescription: + 'Dataset bindings for writeDataPoint-style event recording with schema support and lighter local testing guidance.', + configKey: 'bindings.analyticsEngine', + authoringShape: 'Record', + localStory: 'Supported, but usually tested through integration or thin mocks', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'generator.ts', + 'preview-resources.ts', + 'apps/testing/*' + ], + overview: { + readTime: '4 min read', + title: + 'Use Analytics Engine when the worker should write structured event points, not improvise log transport', + summary: + 'Analytics Engine is modeled cleanly in Devflare config and generated types, but the repo evidence points to a lighter local story than KV, D1, and R2.', + description: + 'That usually means two good habits: keep the write path simple in the worker, and test the event-producing behavior through a thin boundary rather than by inventing a giant analytics simulation.', + highlights: [ + 'Each binding declares a dataset explicitly.', + 'Compile emits Wrangler `analytics_engine_datasets`.', + 'Type generation maps these bindings to `AnalyticsEngineDataset` in `env.d.ts`.', + 'Preview naming exists, but datasets are not provisioned or deleted by Devflare because they are created on first write.' + ], + bestFor: 'Structured analytics or event logging inside worker code', + authoringParagraphs: [ + 'The Analytics Engine binding is conceptually simple: pick a dataset name and write data points to it from the worker path that owns the event.', + 'What matters more than the config shape is resisting the urge to build a fake analytics platform around it just to write the first tests.' + ], + authoringSnippet: { + title: 'Analytics Engine binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'analytics-worker', + bindings: { + analyticsEngine: { + APP_ANALYTICS: { + dataset: 'app-analytics' + } + } + } +})` + }, + fitBullets: [ + 'Use Analytics Engine when the worker should record structured event points as part of handling real traffic or jobs.', + 'Keep analytics writes narrow and explicit so they stay easy to review.', + 'If the data is really application state, it probably belongs in D1 or another durable store instead of analytics.' + ], + caveatBullets: [ + 'The repo does not show a dedicated analytics helper surface comparable to `cf.queue.trigger()` or `env.DB.prepare()`.', + 'Preview-scoped dataset names can be materialized, but Devflare does not provision or delete datasets because Analytics Engine creates them on first write.', + 'Tests should focus on event-producing behavior rather than pretending you need a full local analytics backend.' + ], + caveatCallout: { + tone: 'info', + title: 'This binding is about a write path', + body: [ + 'Document the write contract clearly and keep the testing story light. That is more useful than inventing an elaborate fake dataset universe.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'Analytics Engine has a straightforward compiler story, plus a preview note that matters because datasets are auto-created on first write instead of provisioned like buckets or databases.', + description: + 'That is the core reason the docs should separate it from storage bindings: the worker env shape is familiar, but the resource lifecycle behaves differently.', + highlights: [ + 'Compile emits `analytics_engine_datasets`.', + 'Type generation maps the env binding to `AnalyticsEngineDataset`.', + 'Preview naming can materialize dataset names for scoped environments.', + 'Provision and cleanup are intentionally lighter because datasets are created by writing to them.' + ], + normalizationFact: + 'The authored shape is a simple dataset mapping; the interesting behavior is lifecycle, not deep normalization', + compileTarget: 'Wrangler `analytics_engine_datasets`', + previewNote: + 'Preview names can change, but Devflare does not provision or delete Analytics Engine datasets for you', + normalizationParagraphs: [ + 'Analytics Engine bindings are a small schema surface: a binding name maps to a dataset name. That keeps authored config simple and predictable.', + 'The more important implementation detail is that datasets are not managed like KV namespaces or buckets. They come to life on write, so preview lifecycle support looks different.' + ], + localRuntimeBullets: [ + 'The repo smoke app and integration tests show `writeDataPoint()` being called through the binding, which is enough to describe the runtime contract.', + 'There is no dedicated analytics helper surface in the test harness โ€” use thin worker tests or explicit mocks instead.', + 'Type generation still matters here because it keeps the env contract clear even when the test story is lighter.' + ], + compileBullets: [ + 'Compile emits dataset entries into Wrangler-facing output.', + 'Preview materialization can rewrite dataset names, but Devflare intentionally does not try to provision or delete those datasets for you.', + 'That lifecycle difference is the main caveat compared with storage or queue resources.' + ], + callout: { + tone: 'warning', + title: 'Name changes do not imply resource management', + body: [ + 'Preview-scoped naming is useful, but it does not mean Devflare owns the full dataset lifecycle the way it can for KV, D1, or queues.' + ] + } + }, + testing: { + readTime: '3 min read', + summary: + 'Analytics Engine tests should stay thin: verify that the worker writes a data point, not that you can recreate Cloudflare analytics locally.', + description: + 'The repo evidence supports that approach. There are examples and smoke checks, but not a big dedicated analytics test harness pretending to be the platform.', + highlights: [ + 'Test that the worker reaches `writeDataPoint()` when it should.', + 'Use a thin mock or smoke path when needed.', + 'Keep analytics assertions scoped to the event-producing behavior you care about.', + 'Escalate only if analytics delivery is business-critical enough to deserve a higher-level integration lane.' + ], + bestFor: 'Event-write smoke tests and worker behavior that should emit analytics', + defaultHarness: 'A thin worker test or explicit mock around `writeDataPoint()`', + escalation: 'Analytics delivery itself is a release-critical guarantee', + paragraphs: [ + 'The best default is a small test proving the worker attempted the analytics write when the expected request or job happened.', + 'If you later need stronger end-to-end confidence, add a higher-level integration or smoke lane instead of bloating the ordinary unit path.' + ], + mainSnippet: { + title: 'A thin analytics smoke check', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' + +const writes: unknown[] = [] +const analytics = { + writeDataPoint(point: unknown) { + writes.push(point) + } +} + +test('records an analytics point', () => { + analytics.writeDataPoint({ indexes: ['search'], blobs: ['devflare'] }) + expect(writes).toHaveLength(1) +})` + }, + helperBullets: [ + 'Keep analytics writes behind a small helper if that makes them easier to assert in application-level tests.', + 'Use worker smoke tests around the route or job that should emit the event when you want stronger evidence than a tiny mock.', + 'Do not confuse โ€œwe called writeDataPointโ€ with โ€œthe whole reporting stack is perfectโ€ unless you added a real integration path for that.' + ], + caveatBullets: [ + 'The ordinary docs should not imply that Devflare ships a full local Analytics Engine simulator.', + 'If analytics delivery is business-critical, put it in a dedicated smoke or release lane instead of overfitting every local test.', + 'Preview dataset names may differ, so if that matters operationally, test the generated naming separately.' + ], + callout: { + tone: 'accent', + title: 'Thin and explicit wins here too', + body: [ + 'Analytics bindings are easiest to trust when the worker writes a clearly reviewable point and the tests prove that narrow behavior directly.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example writes one analytics event from one route, which is usually all you need to teach the binding shape clearly.', + description: + 'It keeps the dataset name visible, the event payload small, and the worker boundary obvious.', + highlights: [ + 'One dataset binding is enough to show the pattern.', + 'The route is tiny because the interesting part is the event write.', + 'The event payload should be reviewable, not mysterious.', + 'This same pattern works for search, app, or audit analytics.' + ], + configFocus: 'Explicit dataset naming', + runtimeShape: 'Call `writeDataPoint()` during a request', + bestUse: 'Search analytics, request logging, and event emission', + configSnippet: { + title: 'Minimal Analytics Engine config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'analytics-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + analyticsEngine: { + APP_ANALYTICS: { + dataset: 'app-analytics' + } + } + } +})` + }, + usageSnippet: { + title: 'Write one analytics point in the worker', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + env.APP_ANALYTICS.writeDataPoint({ + indexes: ['search'], + blobs: ['devflare query'] + }) + + return new Response('recorded') +}` + }, + notes: [ + 'Keep the event payload small and explicit so you can reason about what the worker is writing.', + 'If the real event shape grows richer later, this tiny route still teaches the binding contract.' + ], + callout: { + tone: 'info', + title: 'A route can teach the whole binding', + body: [ + 'For Analytics Engine, one request that writes one point is already enough to teach the env shape and the operational habit.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/core-guides-6.ts b/apps/documentation/src/lib/docs/content/bindings/core-guides-6.ts new file mode 100644 index 0000000..d9d7f8b --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/core-guides-6.ts @@ -0,0 +1,235 @@ +import type { BindingGuideDefinition } from './shared' + +export const bindingGuidesPart6: BindingGuideDefinition[] = [ + { + slugBase: 'send-email', + label: 'Send Email', + categoryDescription: + 'Outbound email bindings with real local support, plus an important distinction from inbound email event handlers.', + configKey: 'bindings.sendEmail', + authoringShape: + 'Record', + localStory: 'Outbound local support; distinct from inbound email event testing', + sourcePages: [ + 'schema-bindings.ts', + 'compiler.ts', + 'send-email.ts', + 'simple-context.ts', + 'case12/*' + ], + overview: { + readTime: '4 min read', + title: + 'Use Send Email when the worker should send outbound email with explicit address rules', + summary: + 'Send Email is a real binding surface in Devflare, and it is worth documenting separately from inbound `src/email.ts` handlers so the two flows do not get blurred together.', + description: + 'That distinction matters because outbound email is a binding you call from worker code, while inbound email handling is a worker event surface with its own test helper story.', + highlights: [ + 'Config can restrict a binding to one destination or to explicit sender and recipient allow-lists.', + 'Compiler emits the Wrangler `send_email` entries.', + 'Local runtime supports outbound send-email bindings directly.', + 'Inbound email testing uses the `email` helper surface, which is related but not the same contract.' + ], + bestFor: 'Outbound notification email and controlled email-sending paths from worker code', + authoringParagraphs: [ + 'Send Email bindings are easiest to trust when the allowed addresses are visible in config rather than buried in some last-minute secret or helper wrapper.', + 'Devflare validates the main mutual-exclusion rule here too: use either one `destinationAddress` or a list of `allowedDestinationAddresses`, not both.' + ], + authoringSnippet: { + title: 'Send Email binding authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'email-worker', + bindings: { + sendEmail: { + TRANSACTIONAL_EMAIL: { + allowedDestinationAddresses: ['ops@example.com'], + allowedSenderAddresses: ['noreply@example.com'] + }, + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + } +})` + }, + fitBullets: [ + 'Use Send Email when the worker needs to send notifications or transactional messages outward.', + 'Keep address restrictions explicit so the worker cannot quietly send anywhere it pleases.', + 'Do not confuse outbound send-email bindings with inbound email processing handlers.' + ], + caveatBullets: [ + '`destinationAddress` and `allowedDestinationAddresses` are mutually exclusive in one binding definition.', + 'The local story for outbound email is strong, but it should still be documented separately from inbound email event helpers.', + 'Preview resource lifecycle does not manage email addresses the way it manages storage resources, because the binding compiles the address rules as-is.' + ], + caveatCallout: { + tone: 'warning', + title: 'Outbound is not inbound', + body: [ + '`env.TRANSACTIONAL_EMAIL.send(...)` and `src/email.ts` handler tests are connected by the domain, but they are different contracts and should be documented that way.' + ] + } + }, + internals: { + readTime: '3 min read', + summary: + 'Send Email compiles into Wrangler output, normalizes message input at runtime, and supports local address restrictions instead of treating email as an unbounded free-for-all.', + description: + 'That runtime normalization is worth calling out because it lets worker code send higher-level message shapes while Devflare translates them into the lower-level form the email path needs.', + highlights: [ + 'Compiler emits `send_email` entries from the authored binding rules.', + 'Runtime helpers normalize composed outbound messages into the raw email form when needed.', + 'Local bindings respect sender and destination restrictions.', + 'Env wrapping can surface locally created send-email bindings cleanly in tests and dev.' + ], + normalizationFact: + 'The schema normalizes address restrictions and runtime message helpers normalize composed email input', + compileTarget: 'Wrangler `send_email`', + previewNote: + 'Address rules compile as authored; there is no separate preview resource lifecycle for email destinations', + normalizationParagraphs: [ + 'The schema work here is less about ids and more about safety rules: which addresses are permitted and which combinations are invalid.', + 'At runtime, Devflare can normalize higher-level email message shapes into raw MIME-backed delivery when the outbound path needs it.' + ], + localRuntimeBullets: [ + 'Local send-email bindings can be created and enforced in the default runtime/test context.', + 'Address restrictions are part of the local contract, which keeps the binding honest during development.', + 'Inbound email helper APIs exist too, but they serve the inbound event story rather than replacing outbound bindings.' + ], + compileBullets: [ + 'Compile turns the authored send-email rules into Wrangler-facing `send_email` entries.', + 'The binding rules are emitted as-is; there is no preview resource provisioning story for destination addresses or sender allow-lists.', + 'The runtime normalization step is the subtle part worth documenting because it shapes how friendly outbound code can look.' + ], + callout: { + tone: 'info', + title: 'Safety rules are part of the binding', + body: [ + 'The point of the schema is not only to make email possible. It is also to keep where the worker may send email visible and reviewable.' + ] + } + }, + testing: { + readTime: '4 min read', + summary: + 'Send Email is stronger locally than many platform-service bindings because outbound email can be exercised in the default harness, while inbound email has its own related helper surface.', + description: + 'That means the docs should teach both the outbound binding test and the conceptual split from inbound email event tests, so people do not mix the two up.', + highlights: [ + 'Use the local harness for outbound send-email bindings.', + 'Use the `email` helper surface when you are testing inbound `src/email.ts` handling instead.', + 'Keep one test around the actual outbound binding contract, not only helper wrappers.', + 'Address allow-lists are worth testing because they are part of the safety contract.' + ], + bestFor: 'Outbound notification checks and address-restriction behavior', + defaultHarness: '`createTestContext()` plus `env.TRANSACTIONAL_EMAIL.send(...)`', + escalation: + 'The system has external email delivery requirements beyond the local binding path', + paragraphs: [ + 'Start with one direct outbound send call through the binding and verify the success or allow-list behavior you actually care about.', + 'If you are testing inbound processing, switch mental models entirely and use the email event helper path instead of forcing everything through the outbound binding.' + ], + mainSnippet: { + title: 'Testing an outbound Send Email binding', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('sends an outbound transactional email', async () => { + await expect(env.TRANSACTIONAL_EMAIL.send({ + from: 'noreply@example.com', + to: 'ops@example.com', + subject: 'Smoke check', + text: 'Hello from Devflare' + })).resolves.toBeUndefined() +})` + }, + helperBullets: [ + 'Use the outbound binding directly when the worker is sending mail.', + 'Use the inbound `email` helper surface (`cf.email.send(...)` from `devflare/test`) when the worker is handling inbound email in `src/email.ts`.', + 'Keep address restrictions visible in tests when those restrictions are part of the safety story.' + ], + caveatBullets: [ + 'Do not document inbound email helper tests as if they were proof of the outbound binding path, or vice versa.', + 'If external delivery or provider-side verification matters, add a separate integration lane rather than overfitting the local harness.', + 'The local harness is great for binding behavior, but email product workflows often still need a higher-level end-to-end check.' + ], + callout: { + tone: 'accent', + title: 'Two email stories, one docs rule', + body: [ + 'Keep outbound binding docs and inbound handler docs adjacent in your head, but separate on the page. That is how people avoid testing the wrong thing.' + ] + } + }, + example: { + readTime: '3 min read', + summary: + 'This example keeps outbound email explicit: one binding, one recipient rule, one worker path that sends one message.', + description: + 'It is enough to teach the binding accurately without dragging inbound processing or full provider workflows into the very first page.', + highlights: [ + 'One outbound binding already teaches the contract.', + 'The allowed destination is visible in config.', + 'The worker path shows the actual send call.', + 'This remains easy to test in the default harness.' + ], + configFocus: 'Explicit destination rules', + runtimeShape: 'Call `send()` from a worker route', + bestUse: 'Transactional or support notifications', + configSnippet: { + title: 'Minimal Send Email config', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'send-email-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + sendEmail: { + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + } +})` + }, + usageSnippet: { + title: 'Send one email from the worker', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.SUPPORT_EMAIL.send({ + from: 'noreply@example.com', + to: 'support@example.com', + subject: 'New support request', + text: 'A customer asked for help.' + }) + + return new Response('sent') +}` + }, + notes: [ + 'Keep the first outbound example narrow so the binding contract stays obvious.', + 'If you also handle inbound email elsewhere in the app, document that on the email-event pages rather than merging the two stories here.' + ], + callout: { + tone: 'info', + title: 'One message is enough to teach the binding', + body: [ + 'You do not need a full notification system on the first page. One send call already proves the important contract.' + ] + } + } + } +] diff --git a/apps/documentation/src/lib/docs/content/bindings/index.ts b/apps/documentation/src/lib/docs/content/bindings/index.ts new file mode 100644 index 0000000..0662614 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/index.ts @@ -0,0 +1,59 @@ +import { compactBindingGuidesPart1 } from './compact-guides-1' +import { compactBindingGuidesPart2 } from './compact-guides-2' +import { bindingGuidesPart1 } from './core-guides-1' +import { bindingGuidesPart2 } from './core-guides-2' +import { bindingGuidesPart3 } from './core-guides-3' +import { bindingGuidesPart4 } from './core-guides-4' +import { bindingGuidesPart5 } from './core-guides-5' +import { bindingGuidesPart6 } from './core-guides-6' +import { createBindingPages, getBindingSlugs } from './shared' + +const activeBindingGuides = [ + bindingGuidesPart1, + bindingGuidesPart2, + bindingGuidesPart3, + bindingGuidesPart4, + bindingGuidesPart5, + bindingGuidesPart6, + compactBindingGuidesPart1, + compactBindingGuidesPart2 +].flat() + +export interface BindingTestingGuideLink { + label: string + overviewSlug: string + testingSlug: string + summary: string + defaultHarness: string + localStory: string + categoryDescription: string +} + +export const bindingTestingGuides: BindingTestingGuideLink[] = activeBindingGuides.map((guide) => { + const slugs = getBindingSlugs(guide) + + return { + label: guide.label, + overviewSlug: slugs.overview, + testingSlug: slugs.testing, + summary: guide.testing.summary, + defaultHarness: guide.testing.defaultHarness, + localStory: guide.localStory, + categoryDescription: guide.categoryDescription + } +}) + +export const bindingDocCategories = activeBindingGuides.map((guide) => { + const slugs = getBindingSlugs(guide) + + return { + id: `${guide.slugBase}-binding-library`, + title: guide.label, + description: guide.categoryDescription, + sidebarDisplay: 'links' as const, + slugs: [slugs.overview, slugs.internals, slugs.testing, slugs.example], + sidebarSlugs: [slugs.overview] + } +}) + +export const bindingDocs = activeBindingGuides.flatMap(createBindingPages) diff --git a/apps/documentation/src/lib/docs/content/bindings/shared.ts b/apps/documentation/src/lib/docs/content/bindings/shared.ts new file mode 100644 index 0000000..51ca7b2 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/shared.ts @@ -0,0 +1,811 @@ +import type { DocCallout, DocCodeSnippet, DocPage, DocSection } from '../../types' + +import { + createBindingHeaderCloudflareDocs, + getCloudflareBindingReference, + getCloudflareRuntimeComparison +} from './cloudflare-reference' +import { createBindingHeaderSupport, createBindingSupportSection } from './support' + +export { + createBindingHeaderCloudflareDocs, + getCloudflareBindingIntro, + getCloudflareBindingReference, + getCloudflareRuntimeComparison +} from './cloudflare-reference' + +export const bindingReferenceGroup = 'Bindings' + +export type ContentDocCodeSnippet = DocCodeSnippet & { + code: string +} + +export interface BindingOverviewDefinition { + readTime: string + title: string + summary: string + description: string + highlights: string[] + bestFor: string + authoringParagraphs: string[] + authoringSnippet: ContentDocCodeSnippet + fitBullets: string[] + caveatBullets: string[] + caveatCallout?: DocCallout + extraSections?: DocSection[] +} + +export interface BindingInternalsDefinition { + readTime: string + summary: string + description: string + highlights: string[] + normalizationFact: string + compileTarget: string + previewNote: string + normalizationParagraphs: string[] + localRuntimeBullets: string[] + compileBullets: string[] + callout?: DocCallout +} + +export interface BindingTestingDefinition { + readTime: string + summary: string + description: string + highlights: string[] + bestFor: string + defaultHarness: string + escalation: string + paragraphs: string[] + mainSnippet: ContentDocCodeSnippet + helperBullets: string[] + caveatBullets: string[] + callout?: DocCallout +} + +export interface BindingExampleDefinition { + readTime: string + summary: string + description: string + highlights: string[] + configFocus: string + runtimeShape: string + bestUse: string + configSnippet: ContentDocCodeSnippet + usageSnippet: ContentDocCodeSnippet + notes: string[] + callout?: DocCallout +} + +export interface BindingGuideDefinition { + slugBase: string + pathBase?: string + label: string + categoryDescription: string + configKey: string + authoringShape: string + localStory: string + compileOutput?: string + sourcePages: string[] + overview: BindingOverviewDefinition + internals: BindingInternalsDefinition + testing: BindingTestingDefinition + example: BindingExampleDefinition +} + +export function getBindingPathBase( + guide: Pick +): string { + if (guide.pathBase) { + return guide.pathBase + } + + switch (guide.slugBase) { + case 'durable-object': + return 'bindings/durable-objects' + + case 'queue': + return 'bindings/queues' + + case 'service': + return 'bindings/services' + + case 'browser': + return 'bindings/browser-rendering' + + default: + return `bindings/${guide.slugBase}` + } +} + +export function getBindingSlugs(guide: Pick): { + overview: string + internals: string + testing: string + example: string +} { + const pathBase = getBindingPathBase(guide) + + return { + overview: pathBase, + internals: `${pathBase}/internals`, + testing: `${pathBase}/testing`, + example: `${pathBase}/example` + } +} + +export function getLegacyBindingSlugs(slugBase: string): { + overview: string + internals: string + testing: string + example: string +} { + return { + overview: `${slugBase}-binding`, + internals: `${slugBase}-internals`, + testing: `${slugBase}-testing`, + example: `${slugBase}-example` + } +} + +export function createBindingInternalsSnippet(guide: BindingGuideDefinition): DocCodeSnippet { + const compileOutput = createBindingCompileOutput(guide) + const authoringFocusLines = findFocusLines( + guide.overview.authoringSnippet.code, + `${guide.configKey.split('.').at(-1)}:` + ) + + return { + title: `${guide.label} config and emitted Wrangler output`, + description: + 'Use this when you need to check how the Devflare config becomes Wrangler-compatible config.', + activeFile: 'devflare.config.ts', + structure: [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder', muted: true }, + { path: 'src/fetch.ts', kind: 'file', muted: true }, + { path: '.devflare', kind: 'folder' }, + { path: '.devflare/wrangler.jsonc' } + ], + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + code: guide.overview.authoringSnippet.code, + focusLines: authoringFocusLines ? [authoringFocusLines] : undefined + }, + { + path: '.devflare/wrangler.jsonc', + language: 'json', + code: compileOutput, + focusLines: [[2, Math.max(2, compileOutput.split('\n').length - 1)]] + } + ] + } +} + +export function createBindingCompileOutput(guide: BindingGuideDefinition): string { + if (guide.compileOutput) { + return guide.compileOutput + } + + switch (guide.slugBase) { + case 'kv': + return String.raw`{ + "kv_namespaces": [ + { "binding": "CACHE", "id": "kv-namespace-id" } + ] +}` + + case 'd1': + return String.raw`{ + "d1_databases": [ + { "binding": "DB", "database_id": "d1-database-id" } + ] +}` + + case 'r2': + return String.raw`{ + "r2_buckets": [ + { "binding": "ASSETS", "bucket_name": "assets-bucket" } + ] +}` + + case 'durable-object': + return String.raw`{ + "durable_objects": { + "bindings": [ + { "name": "ROOM", "class_name": "ChatRoom" } + ] + } +}` + + case 'queue': + return String.raw`{ + "queues": { + "producers": [ + { "binding": "JOBS", "queue": "jobs-queue" } + ], + "consumers": [ + { "queue": "jobs-queue", "dead_letter_queue": "jobs-dlq", "max_retries": 3 } + ] + } +}` + + case 'service': + return String.raw`{ + "services": [ + { "binding": "MATH_SERVICE", "service": "math-service" } + ] +}` + + case 'ai': + return String.raw`{ + "ai": { + "binding": "AI" + } +}` + + case 'vectorize': + return String.raw`{ + "vectorize": [ + { "binding": "DOCUMENT_INDEX", "index_name": "document-index" } + ] +}` + + case 'hyperdrive': + return String.raw`{ + "hyperdrive": [ + { "binding": "DB", "id": "hyperdrive-id" } + ] +}` + + case 'browser': + return String.raw`{ + "browser": { + "binding": "BROWSER" + } +}` + + case 'analytics-engine': + return String.raw`{ + "analytics_engine_datasets": [ + { "binding": "APP_ANALYTICS", "dataset": "app-analytics" } + ] +}` + + case 'send-email': + return String.raw`{ + "send_email": [ + { "name": "SUPPORT_EMAIL", "destination_address": "support@example.com" } + ] +}` + + default: + return String.raw`{ + "bindings": [] +}` + } +} + +export function findFocusLines(code: string, marker: string): [number, number] | undefined { + const lines = code.split('\n') + const matchIndex = lines.findIndex((line) => line.includes(marker)) + + if (matchIndex === -1) { + return undefined + } + + const start = Math.max(1, matchIndex + 1) + const end = Math.min(lines.length, start + 4) + return [start, end] +} + +export function bindingDocPath(slug: string): string { + return `/docs/${slug}` +} + +export function inlineCodeFact(value: string): string { + return `\`${value.replaceAll('`', '')}\`` +} + +export function applicationSourcePages(sourcePages: string[]): string[] { + return sourcePages.filter( + (source) => !/(^|\/)(?:tests?|test|apps\/testing)(?:\/|$)/i.test(source) + ) +} + +export const bindingTestOnlyContentPattern = + /\b(?:bun:test|devflare\/test|createTestContext|createOfflineEnv|createMock[A-Z]|cf\.worker|env\.dispose|expect\s*\(|describe\s*\(|test\s*\(|tests?|testing|assert)\b/i + +export function isApplicationOnlyText(value: string): boolean { + return !bindingTestOnlyContentPattern.test(value) +} + +export function applicationExampleSummary(guide: BindingGuideDefinition): string { + if (isApplicationOnlyText(guide.example.summary)) { + return guide.example.summary + } + + return `A real ${guide.label} application path with config and runtime code kept side by side.` +} + +export function applicationExampleDescription(guide: BindingGuideDefinition): string { + if (isApplicationOnlyText(guide.example.description)) { + return guide.example.description + } + + return `Use this as the application-focused ${guide.label} example before you add feature-specific abstractions around the binding.` +} + +export function applicationExampleHighlights(guide: BindingGuideDefinition): string[] { + const highlights = guide.example.highlights.filter(isApplicationOnlyText) + + return highlights.length > 0 + ? highlights + : [ + 'One config block names the Cloudflare resource or product surface.', + 'One runtime path performs the work through the generated env binding.', + 'Production ownership and fallback behavior stay visible next to the route.' + ] +} + +export function applicationExampleFact(value: string, fallback: string): string { + return isApplicationOnlyText(value) ? value : fallback +} + +export function applicationExampleNotes(guide: BindingGuideDefinition): string[] { + const notes = guide.example.notes.filter(isApplicationOnlyText) + + return notes.length > 0 + ? notes + : [ + `Keep the first ${guide.label} path small enough to review in one file.`, + 'Add abstractions only after the runtime shape is obvious.' + ] +} + +export function applicationExampleCallouts( + guide: BindingGuideDefinition +): DocCallout[] | undefined { + if (!guide.example.callout) { + return undefined + } + + const text = JSON.stringify(guide.example.callout) + return isApplicationOnlyText(text) ? [guide.example.callout] : undefined +} + +export function createBindingReferenceSection(guide: BindingGuideDefinition): DocSection { + const reference = getCloudflareBindingReference(guide) + + return { + id: 'cloudflare-reference', + title: 'Cloudflare docs vs the Devflare layer', + paragraphs: [ + `${reference.title} is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for \`${guide.configKey}\`.` + ], + cards: [ + { + href: reference.href, + label: 'Reference', + meta: reference.citation, + title: reference.title, + body: reference.description + } + ], + table: { + headers: ['Question', 'Cloudflare docs', 'This Devflare page'], + rows: [ + [ + 'Primary focus', + reference.description, + `How to author \`${guide.configKey}\`, what the runtime surface looks like, and how ${guide.label} fits a Devflare project.` + ], + [ + 'Testing and runtime lens', + getCloudflareRuntimeComparison(guide), + `${guide.localStory}. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape.` + ], + [ + 'When to open it', + 'When you need the platform contract, limits, APIs, or account-level product details.', + 'When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app.' + ] + ] + } + } +} + +export function createBindingDeepDiveSection(guide: BindingGuideDefinition): DocSection { + const slugs = getBindingSlugs(guide) + + return { + id: 'go-deeper', + title: 'Open the next page when you need it', + cards: [ + { + href: bindingDocPath(slugs.internals), + label: 'Subpage', + meta: 'Internals', + title: `${guide.label} internals`, + body: `Check emitted ${guide.internals.compileTarget}, preview behavior, and Cloudflare-specific details.` + }, + { + href: bindingDocPath(slugs.testing), + label: 'Subpage', + meta: 'Testing', + title: `Testing ${guide.label}`, + body: `Pick the ${guide.testing.defaultHarness} path first, then move to remote checks only when the test needs them.` + }, + { + href: bindingDocPath(slugs.example), + label: 'Subpage', + meta: 'Example', + title: `${guide.label} example`, + body: 'Copy a fuller application path when the quick example is too small.' + } + ] + } +} + +export function createBindingPages(guide: BindingGuideDefinition): DocPage[] { + const slugs = getBindingSlugs(guide) + const legacySlugs = getLegacyBindingSlugs(guide.slugBase) + const headerCloudflareDocs = createBindingHeaderCloudflareDocs(guide) + const headerSupport = createBindingHeaderSupport(guide) + + return [ + { + slug: slugs.overview, + aliases: [legacySlugs.overview], + group: bindingReferenceGroup, + navTitle: guide.label, + articleNavigationHidden: true, + readTime: guide.overview.readTime, + eyebrow: 'Binding reference', + title: guide.overview.title, + summary: guide.overview.summary, + description: guide.overview.description, + headerCloudflareDocs, + headerSupport, + highlights: guide.overview.highlights, + facts: [ + { label: 'Config key', value: inlineCodeFact(guide.configKey) }, + { label: 'Authoring shape', value: inlineCodeFact(guide.authoringShape) }, + { label: 'Best for', value: guide.overview.bestFor } + ], + sourcePages: guide.sourcePages, + sections: [ + { + id: 'authoring-shape', + title: 'Add the binding to config', + paragraphs: guide.overview.authoringParagraphs, + snippets: [guide.overview.authoringSnippet] + }, + { + id: 'runtime-usage', + title: 'Use the binding from application code', + paragraphs: [ + `After Devflare generates the worker env, import \`env\` from \`devflare/runtime\` and keep the first ${guide.label} path close to the route, handler, or service method that needs it.`, + 'Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together.' + ], + snippets: [guide.example.usageSnippet] + }, + createBindingSupportSection(guide), + ...(guide.overview.extraSections ?? []), + { + id: 'when-it-fits', + title: 'When this binding fits best', + bullets: guide.overview.fitBullets + }, + { + id: 'notes-that-matter', + title: 'Testing path', + bullets: guide.overview.caveatBullets, + callouts: guide.overview.caveatCallout ? [guide.overview.caveatCallout] : undefined + }, + createBindingDeepDiveSection(guide) + ] + }, + { + slug: slugs.internals, + aliases: [legacySlugs.internals], + group: bindingReferenceGroup, + sidebarHidden: true, + navTitle: `${guide.label} internals`, + readTime: guide.internals.readTime, + eyebrow: 'Under the hood', + title: `How Devflare wires ${guide.label} from config to runtime`, + summary: guide.internals.summary, + description: guide.internals.description, + headerCloudflareDocs, + headerSupport, + highlights: guide.internals.highlights, + facts: [ + { label: 'Normalization', value: guide.internals.normalizationFact }, + { label: 'Compile target', value: guide.internals.compileTarget }, + { label: 'Preview note', value: guide.internals.previewNote } + ], + sourcePages: guide.sourcePages, + sections: [ + { + id: 'normalization', + title: 'How authored config becomes Wrangler config', + paragraphs: guide.internals.normalizationParagraphs, + snippets: [createBindingInternalsSnippet(guide)] + }, + { + id: 'local-runtime', + title: 'What local runtime support covers', + bullets: guide.internals.localRuntimeBullets + }, + { + id: 'compile-preview', + title: 'Compile, preview, and cleanup behavior', + bullets: guide.internals.compileBullets, + callouts: guide.internals.callout ? [guide.internals.callout] : undefined + }, + createBindingReferenceSection(guide) + ] + }, + { + slug: slugs.testing, + aliases: [legacySlugs.testing], + group: bindingReferenceGroup, + sidebarHidden: true, + navTitle: `Testing ${guide.label}`, + readTime: guide.testing.readTime, + eyebrow: 'Testing', + title: `Test ${guide.label} the way Devflare expects it to run`, + summary: guide.testing.summary, + description: guide.testing.description, + headerCloudflareDocs, + headerSupport, + highlights: guide.testing.highlights, + facts: [ + { label: 'Best for', value: guide.testing.bestFor }, + { label: 'Default harness', value: guide.testing.defaultHarness }, + { label: 'Escalate when', value: guide.testing.escalation } + ], + sourcePages: guide.sourcePages, + sections: [ + { + id: 'default-loop', + title: 'Start with the default test loop', + paragraphs: guide.testing.paragraphs, + snippets: [guide.testing.mainSnippet] + }, + { + id: 'helper-surface', + title: 'The helper surface to remember', + bullets: guide.testing.helperBullets + }, + { + id: 'when-to-escalate', + title: 'When to move beyond the default harness', + bullets: guide.testing.caveatBullets, + callouts: guide.testing.callout ? [guide.testing.callout] : undefined + } + ] + }, + { + slug: slugs.example, + aliases: [legacySlugs.example], + group: bindingReferenceGroup, + sidebarHidden: true, + navTitle: `${guide.label} example`, + readTime: guide.example.readTime, + eyebrow: 'Application example', + title: `Use ${guide.label} in a real application path`, + summary: applicationExampleSummary(guide), + description: applicationExampleDescription(guide), + headerCloudflareDocs, + headerSupport, + highlights: applicationExampleHighlights(guide), + facts: [ + { label: 'Config focus', value: guide.example.configFocus }, + { + label: 'Runtime shape', + value: applicationExampleFact( + guide.example.runtimeShape, + `${guide.label} calls from worker application code` + ) + }, + { + label: 'Best use', + value: applicationExampleFact(guide.example.bestUse, guide.overview.bestFor) + } + ], + sourcePages: applicationSourcePages(guide.sourcePages), + sections: [ + { + id: 'configure-it', + title: 'Start by wiring the binding clearly in config', + snippets: [guide.example.configSnippet] + }, + { + id: 'application-flow', + title: 'Build the application flow around the binding', + paragraphs: [ + `Treat this as the app-level ${guide.label} path: the route, event handler, or service module receives a real request and uses the binding to do useful work.`, + 'Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early.' + ], + snippets: [guide.example.usageSnippet], + bullets: applicationExampleNotes(guide) + }, + { + id: 'production-notes', + title: 'Keep production boundaries visible', + bullets: [ + `Config focus: ${guide.example.configFocus}.`, + `Runtime shape: ${applicationExampleFact(guide.example.runtimeShape, `${guide.label} calls from worker application code`)}.`, + `Best use: ${applicationExampleFact(guide.example.bestUse, guide.overview.bestFor)}.` + ], + callouts: applicationExampleCallouts(guide) + } + ] + } + ] +} + +export interface CompactBindingGuideDefinition { + slugBase: string + pathBase?: string + label: string + categoryDescription: string + configKey: string + authoringShape: string + localStory: string + sourcePages: string[] + compileTarget: string + envType: string + defaultHarness: string + testHelper: string + bestFor: string + remoteBoundary: string + configSnippet: ContentDocCodeSnippet + usageSnippet: ContentDocCodeSnippet + testSnippet?: ContentDocCodeSnippet + compileOutput: string + overviewSections?: DocSection[] +} + +export function createCompactBindingGuide( + definition: CompactBindingGuideDefinition +): BindingGuideDefinition { + return { + slugBase: definition.slugBase, + pathBase: definition.pathBase, + label: definition.label, + categoryDescription: definition.categoryDescription, + configKey: definition.configKey, + authoringShape: definition.authoringShape, + localStory: definition.localStory, + compileOutput: definition.compileOutput, + sourcePages: definition.sourcePages, + overview: { + readTime: '3 min read', + title: `Use ${definition.label} in a Worker`, + summary: `Add the ${definition.label} config, call ${definition.envType} from worker code, and start with the local test path Devflare supports.`, + description: + 'Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit.', + highlights: [ + `Configure it with \`${definition.configKey}\`.`, + `Use ${definition.envType} from worker code.`, + `Start local with ${definition.defaultHarness}.`, + 'Use Cloudflare-backed checks when the product behavior itself is what you need to prove.' + ], + bestFor: definition.bestFor, + authoringParagraphs: [ + `Add \`${definition.configKey}\` to \`devflare.config.ts\`, then use the generated env binding from Worker code.`, + 'Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious.' + ], + authoringSnippet: definition.configSnippet, + fitBullets: [ + `Use ${definition.label} when ${definition.bestFor.toLowerCase()}.`, + 'Keep binding names stable and uppercase in examples so generated Env declarations remain predictable.', + 'Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields.' + ], + caveatBullets: [ + `Start with ${definition.defaultHarness} for config-backed local worker tests.`, + `Use ${definition.testHelper} for small unit tests that only need deterministic application behavior.`, + 'Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing.' + ], + extraSections: definition.overviewSections + }, + internals: { + readTime: '2 min read', + summary: `${definition.label} compiles from \`${definition.configKey}\` to ${definition.compileTarget}, with local/test behavior called out explicitly.`, + description: + 'Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code.', + highlights: [ + `Compile target: ${definition.compileTarget}.`, + `Env type: ${definition.envType}.`, + `Default test lane: ${definition.defaultHarness}.` + ], + normalizationFact: `Devflare normalizes \`${definition.configKey}\` before emitting ${definition.compileTarget}`, + compileTarget: definition.compileTarget, + previewNote: definition.remoteBoundary, + normalizationParagraphs: [ + 'The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects.', + 'The emitted output is shown here so the usage pages do not have to explain compiler details.' + ], + localRuntimeBullets: [ + definition.localStory, + `The default docs recipe uses ${definition.defaultHarness}.`, + `Pure unit tests can use ${definition.testHelper} when the test only needs deterministic application behavior.` + ], + compileBullets: [ + `Devflare emits ${definition.compileTarget} from the native config surface.`, + 'Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way.', + definition.remoteBoundary + ] + }, + testing: { + readTime: '3 min read', + summary: `Test ${definition.label} by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default.`, + description: + 'The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test.', + highlights: [ + `Default harness: ${definition.defaultHarness}.`, + `Pure helper: ${definition.testHelper}.`, + definition.remoteBoundary + ], + bestFor: definition.bestFor, + defaultHarness: definition.defaultHarness, + escalation: + 'The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly', + paragraphs: [ + 'Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns.', + 'When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much.' + ], + mainSnippet: definition.testSnippet ?? definition.usageSnippet, + helperBullets: [ + `Use ${definition.defaultHarness} for config-backed local worker tests.`, + `Use ${definition.testHelper} for pure unit tests.`, + 'Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine.' + ], + caveatBullets: [ + definition.remoteBoundary, + 'Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools.', + 'If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests.' + ], + callout: { + tone: 'warning', + title: 'Local tests should be honest', + body: [ + `For ${definition.label}, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior.` + ] + } + }, + example: { + readTime: '2 min read', + summary: `A compact ${definition.label} recipe with config and worker usage in one application path.`, + description: + 'Use this as the copyable starter before threading the feature into a larger application.', + highlights: [ + 'One config block.', + 'One runtime call path.', + 'One production boundary to keep visible.' + ], + configFocus: definition.configKey, + runtimeShape: definition.envType, + bestUse: definition.bestFor, + configSnippet: definition.configSnippet, + usageSnippet: definition.usageSnippet, + notes: [ + 'Keep the first example short enough to paste into a new Worker.', + definition.remoteBoundary + ], + callout: { + tone: 'accent', + title: 'Thread this into the next recipe', + body: [ + 'Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order.' + ] + } + } + } +} diff --git a/apps/documentation/src/lib/docs/content/bindings/support.ts b/apps/documentation/src/lib/docs/content/bindings/support.ts new file mode 100644 index 0000000..5b65348 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/bindings/support.ts @@ -0,0 +1,123 @@ +import type { DocHeaderSupport, DocSection } from '../../types' +import { supportCoverageTooltips } from '../start-here/shared' +import type { BindingGuideDefinition } from './shared' + +export type BindingSupportLevel = 'Full' | 'Remote' | 'Limited' + +const bindingSupportLevelsBySlugBase: Record = { + kv: 'Full', + d1: 'Full', + r2: 'Full', + 'durable-object': 'Full', + queue: 'Full', + service: 'Full', + ai: 'Remote', + vectorize: 'Remote', + hyperdrive: 'Full', + browser: 'Full', + 'analytics-engine': 'Remote', + 'send-email': 'Full', + 'rate-limiting': 'Full', + 'version-metadata': 'Full', + 'worker-loaders': 'Full', + 'secrets-store': 'Full', + 'ai-search': 'Remote', + 'mtls-certificates': 'Remote', + 'dispatch-namespaces': 'Remote', + workflows: 'Full', + pipelines: 'Remote', + images: 'Full', + 'media-transformations': 'Full', + artifacts: 'Remote', + containers: 'Full' +} + +export function getBindingSupportLevel( + guide: Pick +): BindingSupportLevel { + return bindingSupportLevelsBySlugBase[guide.slugBase] ?? 'Remote' +} + +export function createBindingHeaderSupport(guide: BindingGuideDefinition): DocHeaderSupport { + const supportLevel = getBindingSupportLevel(guide) + + const headerTooltips: Record = { + Full: + 'Full - Devflare can cover the ordinary local workflow for this surface without needing Cloudflare for the first development loop.', + Remote: + 'Remote - Devflare can wire the surface locally, but full fidelity depends on Cloudflare infrastructure or platform behavior.', + Limited: + 'Limited - Devflare has a supported lane here, but the local contract is intentionally narrower than Cloudflare.' + } + + return { + label: supportLevel, + tooltip: headerTooltips[supportLevel] + } +} + +function getBindingSupportSummary( + level: BindingSupportLevel, + guide: BindingGuideDefinition +): string { + switch (level) { + case 'Full': + return `Devflare can run useful ${guide.label} application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior.` + + case 'Remote': + return 'Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion.' + + case 'Limited': + return `Devflare has a real lane for ${guide.label}, but the local contract is intentionally narrower than Cloudflare's hosted product. The docs call out the supported local path and the remote boundary separately.` + } +} + +function getBindingLocalSupportBody( + level: BindingSupportLevel, + guide: BindingGuideDefinition +): string { + if (guide.slugBase === 'browser') { + return 'Local support is the Devflare browser-rendering shim: the dev server starts a loopback-only browser bridge and binding worker that browser libraries can call during local development. Treat it as a practical local/dev path, then use Cloudflare for hosted Browser Rendering limits, session behavior, and product fidelity.' + } + + if (guide.slugBase === 'containers') { + return 'Containers have full local support when Docker or Podman is reachable and the image can be built or inspected without Cloudflare. Devflare builds Dockerfile paths offline-first, runs the container on loopback, and exposes fetch, logs, state, stop, and destroy helpers. Cloudflare still owns deployed rollout, registry availability, SSH, scaling, and hosted platform behavior.' + } + + if (level === 'Full') { + return `${guide.localStory}. Start locally with ${guide.testing.defaultHarness}; that lane should cover the normal ${guide.label} application flow without requiring a Cloudflare connection.` + } + + if (level === 'Remote') { + return `${guide.localStory}. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior.` + } + + return `${guide.localStory}. Use the documented local lane only for the behavior Devflare explicitly models, and keep the narrower boundary visible in code review.` +} + +function getBindingRemoteSupportBody( + level: BindingSupportLevel, + guide: BindingGuideDefinition +): string { + if (level === 'Full') { + return `Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only ${guide.label} details.` + } + + return `Use Cloudflare when ${guide.testing.escalation.toLowerCase()}. This is the lane for full ${guide.label} product fidelity, remote state, lifecycle behavior, and platform-specific limits.` +} + +export function createBindingSupportSection(guide: BindingGuideDefinition): DocSection { + const supportLevel = getBindingSupportLevel(guide) + + return { + id: 'local-and-remote-support', + title: 'Local and Remote Support', + label: supportLevel, + labelTooltip: supportCoverageTooltips[supportLevel], + paragraphs: [ + getBindingSupportSummary(supportLevel, guide), + getBindingLocalSupportBody(supportLevel, guide), + getBindingRemoteSupportBody(supportLevel, guide) + ] + } +} diff --git a/apps/documentation/src/lib/docs/content/build-apps.ts b/apps/documentation/src/lib/docs/content/build-apps.ts new file mode 100644 index 0000000..b3858fb --- /dev/null +++ b/apps/documentation/src/lib/docs/content/build-apps.ts @@ -0,0 +1,827 @@ +๏ปฟimport type { DocPage } from '../types' + +const docsLink = (slug: string): string => `/docs/${slug}` + +const r2WorkerDeliveryCode = String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ env, params }: FetchEvent): Promise { + const object = await env.FILES.get(params.key) + if (!object) { + return new Response('Not Found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', + 'Cache-Control': 'private, max-age=0' + } + }) +}` + +export const buildAppsDocs: DocPage[] = [ + { + slug: 'storage-bindings', + group: 'Guides', + navTitle: 'Storage strategy', + readTime: '6 min read', + eyebrow: 'Binding strategy', + title: 'Choose the right storage binding first, then let the binding guides own the mechanics', + summary: + 'Use this page to choose between KV, D1, R2, and Hyperdrive. Once the shape is clear, open the binding-specific guide for authoring, testing, and examples instead of reading several smaller pages that all repeat the same decision badly.', + description: + 'This is the storage chooser, not a second binding reference shelf. Use it when the question is โ€œwhich storage shape fits this worker?โ€ Then jump into the guide that owns the actual runtime, compile, testing, and preview details for that storage binding.', + highlights: [ + 'KV fits keyed lookups and cache-like state; D1 fits query-shaped data; R2 fits object storage; Hyperdrive fits existing remote Postgres paths.', + 'Stable names in config are still the safest default for name-based storage bindings.', + 'R2 file delivery is an app-architecture decision, not just a binding checkbox.', + 'The binding guides own the mechanics; this page owns the decision rules.' + ], + facts: [ + { + label: 'Best for', + value: 'Choosing between KV, D1, R2, and Hyperdrive before you dive into one binding guide' + }, + { + label: 'Main question', + value: + 'Is the data keyed, query-shaped, object-shaped, or an existing remote database connection?' + }, + { + label: 'Safest default', + value: 'Prefer stable names in config when the binding supports them' + }, + { label: 'Open next', value: 'The specific binding guide once the storage shape is clear' } + ], + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/config/schema.ts', + 'schema-bindings.ts', + 'schema-normalization.ts', + 'resource-resolution.ts' + ], + sections: [ + { + id: 'choose-the-shape', + title: 'Choose the storage shape before you choose the syntax', + paragraphs: [ + 'The weirdest storage mistakes usually come from choosing by familiarity instead of by data shape. Devflare already has strong per-binding guides for authoring and testing, so this page should stay at the decision boundary instead of pretending to be four shorter reference pages glued together.', + 'Once the storage shape is obvious, the binding guide should take over. That keeps the library cleaner and makes the per-binding pages easier to trust.' + ], + table: { + headers: ['Binding', 'Reach for it when', 'Usually the wrong fit'], + rows: [ + [ + '`KV`', + 'You need keyed lookups, cache-like state, feature flags, or lightweight session markers.', + 'You need relational queries, joins, or object delivery.' + ], + [ + '`D1`', + 'You need SQL, relations, filters, or schema-shaped data.', + 'You only need key lookup or one blob of file data.' + ], + [ + '`R2`', + 'You need objects, uploads, generated files, or browser-facing file delivery through a Worker.', + 'You need query semantics or tiny cache records.' + ], + [ + '`Hyperdrive`', + 'You already have a remote PostgreSQL system and the worker should reach it through Cloudflare acceleration.', + 'A local-first or greenfield schema could live in D1 instead.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'The page boundary is deliberate', + body: [ + 'This page should help you pick the binding. The actual binding guides should explain how to author it, test it, preview it, and ship it.' + ] + } + ] + }, + { + id: 'stable-names', + title: 'Stable names are still the calmest authoring default', + paragraphs: [ + 'Name-based storage bindings stay readable in source review and let Devflare resolve the noisy ids later when build, deploy, or config-print flows actually need them.', + 'That rule does not mean every binding works the same way, but it does keep the source-of-truth shape calmer for KV, D1, and Hyperdrive while R2 keeps its already-readable bucket names.' + ], + snippets: [ + { + title: 'Stable-name storage authoring', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'storage-worker', + bindings: { + kv: { + CACHE: { name: 'cache-kv' } + }, + d1: { + DB: { name: 'app-db' }, + AUDIT: { id: 'existing-d1-id' } + }, + hyperdrive: { + POSTGRES: 'app-postgres' + }, + r2: { + ASSETS: 'app-assets' + } + } +})` + } + ] + }, + { + id: 'r2-delivery-boundary', + title: 'R2 still needs an explicit browser-delivery boundary', + cards: [ + { + title: 'Public assets', + body: 'Use a public bucket on a custom domain when anonymous reads are the product, not an accident.' + }, + { + title: 'Private assets', + body: 'Keep the bucket private and serve through a Worker that owns auth, headers, and cache policy.' + }, + { + title: 'Direct uploads', + body: 'Mint short-lived upload URLs from the backend and store object keys instead of pretending permanent raw URLs are the whole product.' + }, + { + href: docsLink('r2-uploads-and-delivery'), + label: 'Guide', + meta: 'R2 architecture', + title: 'R2 uploads & delivery', + body: 'Open this when the real question is presigned uploads, public versus private delivery, Access protection, signed custom-domain media links, or the right dev-versus-production posture.' + } + ], + paragraphs: [ + 'Devflare gives you real R2 bindings in worker code and tests, but it does not promise a stable browser-facing local bucket URL contract. If the browser needs the file in local dev, route through the app instead of assuming the bucket origin is the interface.' + ], + snippets: [ + { + title: 'Worker-gated file serving keeps the app boundary visible', + language: 'ts', + code: String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ env, params }: FetchEvent): Promise { + const object = await env.FILES.get(params.key) + if (!object) { + return new Response('Not Found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', + 'Cache-Control': 'private, max-age=0' + } + }) +}` + } + ] + }, + { + id: 'open-the-guide', + title: 'Open the binding guide that owns the mechanics', + cards: [ + { + href: docsLink('bindings/kv'), + label: 'Binding guide', + meta: 'KV', + title: 'KV', + body: 'Open the KV guide when the storage shape is keyed lookup, cache-like state, or namespace lifecycle.' + }, + { + href: docsLink('bindings/d1'), + label: 'Binding guide', + meta: 'D1', + title: 'D1', + body: 'Open the D1 guide when the storage shape is query-driven and you need the actual SQL-shaped runtime contract.' + }, + { + href: docsLink('bindings/r2'), + label: 'Binding guide', + meta: 'R2', + title: 'R2', + body: 'Open the R2 guide when the real question is bucket usage, testing, preview naming, or file delivery details.' + }, + { + href: docsLink('bindings/hyperdrive'), + label: 'Binding guide', + meta: 'Hyperdrive', + title: 'Hyperdrive', + body: 'Open the Hyperdrive guide when the worker is reaching an existing PostgreSQL system and the operational caveats matter more than the storage taxonomy.' + } + ] + } + ] + }, + { + slug: 'r2-uploads-and-delivery', + group: 'Guides', + navTitle: 'R2 uploads & delivery', + readTime: '7 min read', + eyebrow: 'Guide', + title: + 'Handle R2 uploads and file delivery explicitly instead of treating bucket URLs as the product', + summary: + 'Use presigned `PUT` URLs for direct uploads, public buckets on custom domains for truly public assets, and private buckets plus Worker auth for protected files. Keep `r2.dev` out of production, and when a preview or environment needs its own bucket, scope it intentionally instead of borrowing production storage.', + description: + 'R2 itself is easy to bind. The hard part is the product boundary: should the browser upload directly, should reads stay behind your Worker, should teammates authenticate through Access, or should expiring custom-domain links be validated by a Worker or WAF rule? This page is the architecture guide for those choices.', + highlights: [ + 'Use short-lived presigned `PUT` URLs for direct browser uploads instead of proxying large files through your app server or Worker.', + 'Use a public bucket on a custom domain only when the files are truly public; otherwise keep the bucket private and let a Worker own auth, headers, and cache policy.', + 'Presigned `GET` URLs work on the R2 S3 endpoint and behave like bearer tokens, but they do not work on custom domains.', + 'If a custom-domain bucket should be teammate-only, put it behind Cloudflare Access; if it needs expiring public links on that custom domain, use a Worker or WAF HMAC validation instead.', + 'Devflare gives you real local R2 bindings in runtime and tests, but it does not promise a stable browser-facing local bucket URL contract, so local browser flows should usually go through your Worker routes.' + ], + facts: [ + { + label: 'Safest upload default', + value: + 'Presigned `PUT` URL plus browser-direct upload plus object key stored in your app database' + }, + { label: 'Safest private delivery default', value: 'Private bucket plus Worker-gated reads' }, + { label: 'Do not ship this as prod delivery', value: '`r2.dev`' }, + { label: 'Team-only fit', value: 'Custom domain plus Cloudflare Access' } + ], + sourcePages: [ + 'README.md', + 'schema-bindings.ts', + 'src/test/simple-context.ts', + 'src/bridge/proxy.ts', + 'packages/devflare/src/test/simple-context.ts', + 'apps/testing/*' + ], + sections: [ + { + id: 'quick-rules', + title: 'The fast rule set', + bullets: [ + 'Use presigned `PUT` URLs for direct user uploads to R2.', + 'Use a public bucket on a custom domain for truly public assets.', + 'Use a private bucket plus Worker authorization for authenticated or tenant-scoped files.', + 'Use Cloudflare Access when the bucket should be visible only to teammates or your organization.', + 'Use a Worker-signed URL flow or WAF HMAC validation for expiring custom-domain media links.', + 'Do not use `r2.dev` for production delivery, and disable `r2.dev` if you protect a custom-domain bucket with Access or WAF so the bucket is not still public there.' + ], + callouts: [ + { + tone: 'accent', + title: 'R2 binding mechanics are not the hard part', + body: [ + 'The architectural decision is whether the browser should talk to a signed upload URL, a public custom domain, or your own Worker route. That choice matters more than the one-line `bindings.r2` config.' + ] + } + ] + }, + { + id: 'uploads', + title: 'The usual safe upload flow is direct upload with a presigned `PUT` URL', + steps: [ + 'The frontend asks your app for upload permission.', + 'Your Worker or backend authenticates the user and validates file type, size, and the target object key.', + 'Your backend returns a short-lived presigned `PUT` URL.', + 'The browser uploads directly to R2.', + 'Your app stores the object key and metadata, not the presigned URL.' + ], + paragraphs: [ + 'This is the usual safe default because large files do not have to stream through your app server or Worker just to end up in object storage anyway.', + "Cloudflare's UGC guidance says the same thing: let the Worker control auth and upload intent, then let the client stream directly to R2. If you need post-upload workflows, R2 event notifications can push object-create events into Queues for moderation, metadata writes, or follow-up processing." + ], + bullets: [ + 'Generate object keys server-side, for example `users//.jpg`.', + 'Restrict `Content-Type` when signing uploads so mismatched uploads fail signature validation.', + 'Keep upload URLs short-lived and treat them as bearer tokens while they remain valid.', + 'Configure bucket CORS when the browser uploads directly.', + 'If uploads arrive from many regions, Local Uploads can improve cross-region write performance without changing the overall architecture.' + ], + cards: [ + { + href: 'https://developers.cloudflare.com/r2/api/s3/presigned-urls/', + label: 'Cloudflare docs', + meta: 'Uploads', + title: 'Presigned URLs', + body: 'Covers supported operations, security considerations, and the custom-domain limitation for presigned URLs.' + }, + { + href: 'https://developers.cloudflare.com/r2/buckets/cors/', + label: 'Cloudflare docs', + meta: 'Browser uploads', + title: 'Configure CORS', + body: 'Use this when browser uploads or downloads cross origins and you need the exact allowed origins, methods, and headers model.' + }, + { + href: 'https://developers.cloudflare.com/r2/buckets/event-notifications/', + label: 'Cloudflare docs', + meta: 'Post-upload workflows', + title: 'R2 event notifications', + body: 'Use this when uploads should trigger queue-driven moderation, indexing, metadata writes, or other follow-up work.' + } + ], + callouts: [ + { + tone: 'success', + title: 'Store object keys, not presigned URLs', + body: [ + 'Presigned URLs are temporary access tokens. The durable thing your app should remember is the object key plus the metadata you care about.' + ] + } + ] + }, + { + id: 'delivery-patterns', + title: 'Choose the file-delivery pattern by who should be able to read the object', + table: { + headers: ['Pattern', 'Use it when', 'Main caveat'], + rows: [ + [ + 'Public bucket on a custom domain', + 'Images, assets, or media should be public and cacheable for anyone.', + 'Use a custom domain for real delivery; `r2.dev` is not the production path.' + ], + [ + 'Private bucket plus Worker-gated reads', + 'Access depends on the current user, tenant, payment state, or other app authorization.', + 'Your Worker becomes the delivery boundary, so own the auth, cache headers, and response metadata deliberately.' + ], + [ + 'Presigned `GET` URL on the S3 endpoint', + 'A download should be directly accessible for a short time without a custom delivery layer.', + 'Presigned URLs are bearer tokens and do not work with custom domains.' + ], + [ + 'Custom domain plus Cloudflare Access', + 'Only teammates or organization users should reach the bucket.', + 'Disable `r2.dev` so the bucket is not still reachable through the public development URL.' + ], + [ + 'Custom domain plus Worker token auth or WAF HMAC validation', + 'You want expiring direct links on `cdn.example.com` without exposing the whole bucket.', + 'This is not the same feature as presigned R2 URLs; you are building or validating the access layer at the custom domain boundary.' + ] + ] + }, + paragraphs: [ + "Cloudflare's public bucket docs are clear about this split: custom domains are the right place for cache, WAF, Access, and other edge controls, while `r2.dev` is a development-oriented public URL and should not be treated as the polished product surface.", + 'When the content is private or app-controlled, the safest default is still a private bucket with a Worker route in front of it. That keeps auth and response headers under your control instead of forcing the bucket URL to become your application boundary.' + ], + cards: [ + { + href: 'https://developers.cloudflare.com/r2/buckets/public-buckets/', + label: 'Cloudflare docs', + meta: 'Public delivery', + title: 'Public buckets', + body: 'Covers custom domains, caching, access control, and the `r2.dev` production warning.' + }, + { + href: 'https://developers.cloudflare.com/r2/tutorials/cloudflare-access/', + label: 'Cloudflare docs', + meta: 'Team-only access', + title: 'Protect an R2 bucket with Access', + body: 'Best when the audience is your own organization rather than anonymous or app-authenticated users.' + }, + { + href: 'https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/', + label: 'Cloudflare docs', + meta: 'Expiring links', + title: 'Configure token authentication', + body: 'Use this when expiring custom-domain media links should be validated with WAF HMAC rules instead of R2 presigned URLs.' + } + ] + }, + { + id: 'dev-and-prod', + title: 'Keep development and production boundaries honest', + paragraphs: [ + "Cloudflare's development guidance says local Worker development uses local simulated bindings by default, and Devflare follows the same practical posture: local R2 bindings are available to your worker code, tests, and bridge helpers without requiring a real remote bucket just to iterate.", + 'Browser-visible local file flows should go through your Worker routes or app routes. Devflare does not promise a stable browser-facing local bucket origin, and depending on one would make local behavior more brittle than the product boundary probably needs to be.' + ], + snippets: [ + { + title: 'Serve a private object through the Worker in local dev and production', + language: 'ts', + code: r2WorkerDeliveryCode + } + ], + bullets: [ + 'Only connect local development to a real remote bucket when you intentionally need integration testing.', + 'Use separate development, staging, or preview buckets instead of production buckets when remote R2 access becomes necessary.', + 'Remote bindings touch real data, incur real costs, and add real latency.', + 'In production, use a custom domain, choose public versus private delivery intentionally, configure CORS deliberately, and consider Local Uploads when uploaders are globally distributed.' + ], + callouts: [ + { + tone: 'warning', + title: 'Remote dev is not a harmless toggle', + body: [ + 'If your local Worker talks to a remote bucket, it is touching real data and real billing surfaces. Prefer separate dev or preview buckets, and avoid pointing local workflows at production uploads unless the test truly requires it.' + ] + } + ] + }, + { + id: 'recommended-defaults', + title: 'A sane default architecture', + bullets: [ + 'Public assets โ†’ public bucket plus custom domain.', + 'User uploads โ†’ presigned `PUT` upload plus object key stored in D1 or another app database.', + 'Private assets โ†’ private bucket plus Worker-gated reads.', + 'Internal assets โ†’ custom domain plus Cloudflare Access.', + 'Custom-domain expiring links โ†’ Worker token auth or WAF HMAC validation.', + 'Preview-owned buckets โ†’ pair the R2 binding with `preview.scope()` so preview cleanup can remove the preview bucket without touching production storage.' + ], + cards: [ + { + href: docsLink('bindings/r2'), + label: 'Binding guide', + meta: 'R2 mechanics', + title: 'R2 binding guide', + body: 'Open this once the architecture choice is done and the next question is the exact binding shape, local runtime behavior, or testing posture.' + }, + { + href: docsLink('config-previews'), + label: 'Configuration', + meta: 'Preview-owned resources', + title: 'Preview-scoped bindings', + body: 'Open this when preview deployments should own separate buckets or other disposable infrastructure that can be cleaned up by scope later.' + }, + { + href: docsLink('create-test-context'), + label: 'Testing', + meta: 'Local harness', + title: 'createTestContext()', + body: 'Open this when the next question is how the local worker-shaped test harness exposes real R2 bindings and helper surfaces.' + } + ], + callouts: [ + { + tone: 'info', + title: 'If you only remember one rule', + body: [ + 'Use presigned URLs for short-lived direct R2 access, but use a Worker or custom-domain auth layer for polished private media delivery.' + ] + } + ] + } + ] + }, + { + slug: 'durable-objects-and-queues', + group: 'Guides', + navTitle: 'State & async patterns', + readTime: '6 min read', + eyebrow: 'Binding strategy', + title: + 'Choose Durable Objects for single-identity state, queues for deferred work, and the binding guides for the mechanics', + summary: + 'Use Durable Objects when one identity should own state or coordination. Use queues when work should happen later, in batches, or with retries. Then open the specific binding guide once the pattern is clear.', + description: + 'This page is the pattern chooser for stateful or deferred work. It should help you decide when a Durable Object, a queue, or a mix of both fits the job without turning into a duplicate reference page for either binding.', + highlights: [ + 'Durable Objects own state and coordination behind one object identity.', + 'Queues own deferred work, batching, retries, and dead-letter behavior.', + 'Some systems use both: the request path or object owns the immediate state, then a queue owns the slower follow-up work.', + 'Preview and testing questions usually belong on the binding guides once the basic pattern choice is done.' + ], + facts: [ + { + label: 'Best for', + value: 'Choosing between stateful identities, background work, or a mix of both' + }, + { label: 'Choose by', value: 'State ownership vs deferred work ownership' }, + { + label: 'Best local proof', + value: 'One real object call or one real queue trigger through the default harness' + }, + { + label: 'Preview warning', + value: + 'Durable Object-heavy previews and queue-owned resources have different release questions' + } + ], + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'README.md', + 'schema-bindings.ts', + 'do-bundler.ts', + 'queue.ts', + 'packages/devflare/src/cli/commands/deploy.ts' + ], + sections: [ + { + id: 'choose-the-pattern', + title: 'Choose the primitive by ownership, not by vibes', + paragraphs: [ + 'The decision is easier when you ask who owns the work. If one stateful identity should serialize and own it, that points toward Durable Objects. If the request can accept the work and let something else finish it later, that points toward queues.', + 'Once that choice is made, the specific binding guide should take over so this page does not try to restate every authoring and testing rule for both bindings.' + ], + table: { + headers: ['Pattern', 'Reach for it when', 'Usually the wrong fit'], + rows: [ + [ + '`Durable Objects`', + 'One identity should own state, coordination, ordering, alarms, or WebSocket-adjacent behavior.', + 'The work is fire-and-forget, batchable, or mainly about retries.' + ], + [ + '`Queues`', + 'The request can enqueue work and return while a consumer handles retries, batching, or slow follow-up tasks.', + 'The user needs the state transition to finish synchronously in the request path.' + ], + [ + '`Use both`', + 'A request or Durable Object owns the immediate state, then enqueues slower side work such as email, indexing, or downstream writes.', + 'One primitive already tells the whole story and the second one would only add ceremony.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'The point is pattern fit, not duplicate reference docs', + body: [ + 'If you already know you need a Durable Object or a queue, the binding guide is the next page. This page is here for the choice, not the full mechanics.' + ] + } + ] + }, + { + id: 'keep-the-shapes-explicit', + title: 'Keep the config shapes explicit once you know the pattern', + paragraphs: [ + 'Both patterns work better when the binding contract is visible in config. Durable Objects should name the object classes or refs clearly, and queues should keep producers, consumers, and dead-letter rules in one authored shape instead of hiding them in deployment-only conventions.' + ], + snippets: [ + { + title: 'Durable Object binding authoring should stay boring and explicit', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'stateful-worker', + files: { + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + durableObjects: { + LOGGER: { + className: 'Logger' + } + } + } +})` + }, + { + title: 'Queue config should keep producer and consumer ownership visible', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-worker', + bindings: { + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue', + deadLetterQueue: 'task-queue-dlq' + } + ] + } + } +})` + } + ] + }, + { + id: 'testing-and-preview-boundaries', + title: 'Testing and preview questions are different for the two patterns', + bullets: [ + 'Durable Object tests are best at local object behavior, identity lookup, and stateful coordination. They do not replace migration or preview-topology checks.', + 'Queue tests are best at direct consumer behavior, retries, batching, and side effects through `cf.queue.trigger()`. They do not replace preview resource lifecycle checks.', + 'Durable Object-heavy preview flows deserve extra care because same-worker preview URLs and migrations have real platform caveats.', + 'If the real question is no longer โ€œwhich primitive fits?โ€ switch to the binding guide or the preview docs before this page starts repeating them badly.' + ], + cards: [ + { + href: docsLink('preview-strategies'), + label: 'Ship & operate', + meta: 'Preview caveats', + title: 'Preview strategies', + body: 'Open this when the real question is how Durable Objects or preview-scoped queue resources change the preview model.' + }, + { + href: docsLink('testing-overview'), + label: 'Testing', + meta: 'Map', + title: 'Testing overview', + body: 'Use the testing map when the next question is about the right harness or which docs own the testing guidance.' + } + ] + }, + { + id: 'open-the-guides', + title: 'Open the binding guide once the pattern is obvious', + cards: [ + { + href: docsLink('bindings/durable-objects'), + label: 'Binding guide', + meta: 'Durable Objects', + title: 'Durable Objects', + body: 'Open the Durable Objects guide for the real binding shape, local tests, migrations, and preview caveats.' + }, + { + href: docsLink('bindings/queues'), + label: 'Binding guide', + meta: 'Queues', + title: 'Queues', + body: 'Open the Queues guide for producer and consumer authoring, queue tests, and preview resource lifecycle details.' + } + ] + } + ] + }, + { + slug: 'multi-workers', + group: 'Guides', + navTitle: 'Worker composition', + readTime: '6 min read', + eyebrow: 'Composition', + title: 'Compose worker families with service bindings when another worker is a real dependency', + summary: + 'Use this page for the architecture question: when a separate worker boundary is justified, how `ref()` and service bindings keep it explicit, and where local tests and release checks should prove the wiring.', + description: + 'The Services guide can explain the mechanics. This page exists for the composition question: when should another worker exist at all, how do you keep the boundary explicit, and which docs own the deeper service details once you commit to it?', + highlights: [ + 'Reach for another worker when the runtime boundary is real, not just because one file feels crowded.', + 'Use `ref()` and service bindings so worker relationships stay explicit in config, tests, and generated output.', + 'One real local service call is the shortest honest proof of the wiring.', + 'Preview isolation depends on resolved worker names, so naming validation still matters after the local test passes.' + ], + facts: [ + { + label: 'Best for', + value: + 'Service bindings, worker families, and deciding when another worker boundary is actually real' + }, + { label: 'Core tools', value: '`ref()`, service bindings, and generated env types' }, + { + label: 'Best local proof', + value: '`createTestContext()` plus one real service call through `env.MY_SERVICE`' + }, + { label: 'Main release risk', value: 'Resolved worker naming and preview topology drift' } + ], + sourcePages: [ + 'packages/devflare/src/config/schema-bindings.ts', + 'README.md', + 'schema-bindings.ts', + 'ref.ts', + 'resolve-service-bindings.ts', + 'generator.ts', + 'case5/*' + ], + sections: [ + { + id: 'choose-the-boundary', + title: 'Choose another worker only when the boundary is real', + paragraphs: [ + 'The goal is not to split one worker just because the file count went up. The goal is to give a real runtime boundary a real worker boundary, then let service bindings make that relationship explicit enough for tooling and review.', + 'That means this page should answer the architecture choice first. The Services guide can take over once the answer is already โ€œyes, another worker should exist.โ€' + ], + table: { + headers: ['If the real thing is...', 'Prefer...', 'Why'], + rows: [ + [ + 'A separate runtime capability or internal API', + '`Service bindings` and another worker', + 'The boundary is a real worker-to-worker relationship, not just shared state.' + ], + [ + 'One stateful identity or serialized mutation lane', + '`Durable Objects`', + 'The core need is state ownership, not another general-purpose service boundary.' + ], + [ + 'Shared data, files, or a background job handoff', + '`KV`, `D1`, `R2`, or `Queues`', + 'The problem is data or deferred work, not a second worker API.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'A good review question', + body: [ + 'Ask โ€œwhat does this second worker own that a binding or Durable Object would not?โ€ before you celebrate the split.' + ] + } + ] + }, + { + id: 'model-the-relationship', + title: 'Model the relationship with `ref()` so the worker family stays explicit', + paragraphs: [ + 'If another worker is real, the relationship belongs in config instead of in copied worker names or half-remembered script references. `ref()` gives Devflare enough structure to follow the dependency into local runtime, generated env types, and compiled output.', + 'Keep the architecture example simple: one referenced worker and one explicit service binding are enough to show the boundary. Named entrypoints are real too, but the Services and generated-types pages own that deeper contract once the worker boundary itself is already justified.' + ], + snippets: [ + { + title: 'Model the worker family with `ref()` and one explicit service binding', + language: 'ts', + code: String.raw`import { defineConfig, ref } from 'devflare/config' + +const mathWorker = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + bindings: { + services: { + MATH_SERVICE: mathWorker.worker + } + } +})` + } + ] + }, + { + id: 'prove-the-wiring', + title: 'Prove the wiring locally, then validate the names before release', + paragraphs: [ + 'The shortest truthful proof is one real service call through the generated env binding. That already shows the config relationship, the local multi-worker setup, and the callable surface the gateway worker will actually use.', + 'But the release question is still different: local tests prove the call path, not that preview or production worker names resolve the way you intended.' + ], + snippets: [ + { + title: 'One real service call through the default harness', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('service binding calls the default worker export', async () => { + expect(await env.MATH_SERVICE.add(5, 3)).toBe(8) +})` + } + ], + bullets: [ + 'Use the bound env service directly when the worker relationship is the thing you want to prove.', + 'Refresh generated types when the service contract changes, and open the generated types page when named entrypoints become part of that contract.', + 'Preview isolation follows resolved worker names, not just which branch variable existed in CI.', + 'Validate compiled or preview naming when the worker family is business-critical.' + ] + }, + { + id: 'open-the-service-lane', + title: 'Open the service-specific pages once the architecture choice is done', + cards: [ + { + href: docsLink('bindings/services'), + label: 'Binding guide', + meta: 'Services', + title: 'Services guide', + body: 'Open the service guide for the exact binding shape, env typing, and compiler behavior once another worker is definitely the right boundary.' + }, + { + href: docsLink('bindings/services/testing'), + label: 'Testing', + meta: 'Services', + title: 'Testing Services', + body: 'Open the service testing guide when the next question is the right default harness or how to test named entrypoints accurately.' + }, + { + href: docsLink('generated-types'), + label: 'Configuration', + meta: 'Typed contracts', + title: 'Generated types', + body: 'Open this page when `ref()` relationships, named entrypoints, or `defineConfig()` typing becomes the real question.' + }, + { + href: docsLink('preview-strategies'), + label: 'Ship & operate', + meta: 'Preview topology', + title: 'Preview strategies', + body: 'Open the preview page when the worker family needs real isolation and the naming model is the release question now.' + }, + { + href: docsLink('testing-overview'), + label: 'Testing', + meta: 'Map', + title: 'Testing overview', + body: 'Use the testing map when the next question is broader than service bindings alone.' + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/configuration.ts b/apps/documentation/src/lib/docs/content/configuration.ts new file mode 100644 index 0000000..02be2f9 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration.ts @@ -0,0 +1 @@ +export { configurationDocs } from './configuration/index' diff --git a/apps/documentation/src/lib/docs/content/configuration/index.ts b/apps/documentation/src/lib/docs/content/configuration/index.ts new file mode 100644 index 0000000..776104d --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration/index.ts @@ -0,0 +1,9 @@ +import { configurationDocsPart1 } from './part-1' +import { configurationDocsPart2 } from './part-2' +import { configurationDocsPart3 } from './part-3' + +export const configurationDocs = [ + configurationDocsPart1, + configurationDocsPart2, + configurationDocsPart3 +].flat() diff --git a/apps/documentation/src/lib/docs/content/configuration/part-1.ts b/apps/documentation/src/lib/docs/content/configuration/part-1.ts new file mode 100644 index 0000000..20da906 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration/part-1.ts @@ -0,0 +1,636 @@ +import type { DocPage } from '../../types' +import { + docsLink, + environmentOverlayCode, + fullConfigExampleCode, + generatedTypesOutputCode, + previewBindingsConfigCode, + previewBindingsLifecycleCode, + projectShapeConfigCode, + runtimeDeploySettingsCode, + typedEnvVarsConfigCode, + typedEnvVarsDotenvCode, + typedEnvVarsRuntimeCode, + workerSurfacesConfigCode +} from './shared' + +export const configurationDocsPart1: DocPage[] = [ + { + slug: 'full-config', + group: 'Devflare', + navTitle: 'Full config', + readTime: '6 min read', + eyebrow: 'Configuration', + title: + 'Scan one full `devflare.config.ts` example with the main current config lanes in one place', + summary: + 'See one canonical `devflare.config.ts` that touches the main current config lanes in a single file, with hover coverage on every property shown in the example.', + description: + 'This page is the quick โ€œshow me the whole shapeโ€ version of Devflare config. It is intentionally full enough to scan the current top-level lanes in one file without turning into a maximal dump of every possible nested variant.', + highlights: [ + 'Use this page when you want the canonical config shape in one glance before opening the deeper pages for one lane.', + 'Every property shown in the example is a real current config key and is covered by inline hover help on this page.', + 'The example keeps binding values readable, using common shorthand where that says the same thing more clearly than an id-heavy object form.', + 'Deeper pages still own the richer variants, caveats, and operational details for each lane.' + ], + facts: [ + { + label: 'Best for', + value: 'Seeing the whole current config shape before you zoom into one subsection' + }, + { + label: 'Reading pattern', + value: + 'Scan the example first, then hover properties, then open the specialist page you actually need' + }, + { + label: 'Important boundary', + value: 'This example is canonical, but not every binding family variant is shown inline' + } + ], + sourcePages: [ + 'src/config/schema.ts', + 'src/config/schema-runtime.ts', + 'src/config/schema-bindings.ts', + 'src/config/schema-build.ts', + 'src/config/schema-env.ts', + 'src/config/compiler.ts' + ], + sections: [ + { + id: 'canonical-example', + title: 'Use one canonical example when you want the whole shape in view', + paragraphs: [ + 'When you already know Devflare is split into config, runtime, testing, and framework lanes, the next practical question is often just: what does a full current config actually look like?', + 'That is what this page is for. The example below touches the major current top-level config lanes in one place, while still staying readable enough for code review and copy-with-intent adaptation.' + ], + snippets: [ + { + title: 'One full config example you can scan top to bottom', + description: + 'Hover any property in the config to see what that lane means. The example is intentionally broad, but the dedicated pages still own the deeper caveats and richer nested variants.', + language: 'ts', + code: fullConfigExampleCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Full does not mean maximal', + body: [ + 'Every property shown above is real and current, but some binding families accept richer object variants than this page needs to show. Use this page as the canonical shape, then open the dedicated binding or configuration page when you need a deeper variant.' + ] + } + ] + }, + { + id: 'lane-map', + title: 'Know what each top-level lane is doing', + table: { + headers: ['Lane', 'What it owns', 'Open next when you need more'], + rows: [ + [ + '`name`, `accountId`, `compatibility*`', + 'Worker identity and runtime posture.', + '`config-basics` and `runtime-deploy-settings`' + ], + [ + '`previews`, `files`, `bindings`, `triggers`', + 'The authored Worker shape: surfaces, bindings, and scheduled intent.', + '`project-shape`, `worker-surfaces`, and `config-previews`' + ], + [ + '`vars`, `secrets`, `env`', + 'Runtime strings, secret declarations, and environment overlays.', + '`config-environments`' + ], + [ + '`routes`, `wsRoutes`, `assets`', + 'Deployment routing, dev WebSocket proxy rules, and static asset delivery.', + '`runtime-deploy-settings`' + ], + [ + '`limits`, `observability`, `migrations`', + 'Operational posture and release-time controls.', + '`runtime-deploy-settings`' + ], + [ + '`rolldown`, `vite`, `wrangler`', + 'Bundler coordination, host integration, and unsupported Wrangler passthrough.', + '`config-basics`, `vite-standalone`, and `svelte-with-rolldown`' + ] + ] + } + }, + { + id: 'go-deeper', + title: 'Open the specialist page once the full picture is clear', + cards: [ + { + label: 'Configuration', + title: 'Need the authoring rules?', + body: 'Open config basics when the question is what should live in authored config versus generated output or deploy-time resolution.', + href: docsLink('config-basics') + }, + { + label: 'Configuration', + title: 'Need the project shape story?', + body: 'Open project shape when the main question is how many Worker surfaces or discovery lanes the package should actually own.', + href: docsLink('project-shape') + }, + { + label: 'Configuration', + title: 'Need preview or environment overlays?', + body: 'Use the environments and previews pages when the full config turns into a question about per-lane overrides or preview-scoped resources.', + href: docsLink('config-environments') + }, + { + label: 'Configuration', + title: 'Need runtime and deploy posture?', + body: 'Open runtime and deploy settings when the question is routes, assets, WebSocket proxy rules, observability, limits, or migrations.', + href: docsLink('runtime-deploy-settings') + } + ] + } + ] + }, + { + slug: 'project-shape', + group: 'Devflare', + navTitle: 'Project shape', + readTime: '5 min read', + eyebrow: 'Configuration', + title: + 'Configure the project shape around explicit file surfaces before the package gets noisy', + summary: + 'Start with one fetch file, then add routes, background handlers, Durable Objects, assets, and transport rules only when the project genuinely needs them.', + description: + 'The config keys that shape a Devflare project are mostly about which files or globs Devflare should treat as real runtime surfaces. Keep that shape small at first, then expand it deliberately instead of letting autodiscovery and generated output become the accidental architecture.', + highlights: [ + 'Start with `files.fetch` when one Worker surface is enough.', + 'Add `files.routes` only when a route tree makes the package easier to read than one large fetch file.', + 'Queue, scheduled, email, Durable Object, workflow, and entrypoint files are all separate surfaces you can opt into explicitly.', + 'Use explicit disable values such as `files.routes: false` or `files.transport: null` when you want autodiscovery out of the way.' + ], + facts: [ + { + label: 'Best for', + value: 'Teams deciding how many runtime surfaces one package actually needs' + }, + { label: 'Primary shape keys', value: '`files.*`, `assets`, `routes`, and `wsRoutes`' }, + { + label: 'Safest habit', + value: 'Add one surface only when the current project shape truly asks for it' + } + ], + sourcePages: [ + 'packages/devflare/src/config/schema.ts', + 'README.md', + 'schema-runtime.ts', + 'config-autodiscovery.test.ts' + ], + sections: [ + { + id: 'start-small', + title: 'Start with the smallest honest project shape', + paragraphs: [ + 'Devflare does not ask you to configure every possible Worker surface up front. The clean starting point is one fetch entry, then a route tree, a queue consumer, Durable Objects, or other surfaces only when the package actually needs them.', + 'That keeps the authored config readable in code review and stops the project structure from silently inheriting complexity just because a default glob or generated file happened to exist.' + ], + steps: [ + 'Start with `files.fetch` for the main HTTP Worker surface.', + 'Add `files.routes` when multiple URLs deserve their own modules.', + 'Add background surfaces such as `queue`, `scheduled`, or `email` only when the package truly owns those events.', + 'Add `durableObjects`, `entrypoints`, `workflows`, or `transport` only when the runtime contract calls for them.', + 'Keep static assets, deployment routes, and WebSocket proxy rules in their own config lanes instead of smuggling them into file conventions.' + ], + callouts: [ + { + tone: 'success', + title: 'Project shape is part of architecture', + body: [ + 'If the config says one package owns five runtime surfaces, reviewers should be able to see why. Devflare works best when that shape is explicit instead of accidental.' + ] + } + ] + }, + { + id: 'surface-map', + title: 'Know which keys actually shape the project', + table: { + headers: ['Config lane', 'Use it when', 'Project effect'], + rows: [ + [ + '`files.fetch`', + 'One main Worker surface should own request-wide behavior.', + 'Points Devflare at the fetch entry you author directly.' + ], + [ + '`files.routes`', + 'The project needs route modules or a mounted route prefix.', + 'Lets a route tree sit beside or replace the main fetch file.' + ], + [ + '`files.queue`, `files.scheduled`, `files.email`', + 'The package consumes background or platform-triggered events.', + 'Adds separate handler files for those runtime surfaces.' + ], + [ + '`files.durableObjects`, `files.entrypoints`, `files.workflows`', + 'The project needs stateful classes, named entrypoints, or workflow definitions.', + 'Turns globs into additional Worker-owned code surfaces Devflare can discover and bundle.' + ], + [ + '`files.transport`', + 'Custom value transport is needed for richer Worker or Durable Object contracts.', + 'Lets you point at one explicit transport file, or disable autodiscovery with `null`.' + ], + [ + '`assets`, `routes`, `wsRoutes`', + 'Static files, deployment routing, or dev WebSocket proxy behavior need their own config.', + 'Keeps non-handler project concerns out of the file-surface lane.' + ] + ] + }, + snippets: [ + { + title: 'One config can stay readable even when the package grows a few real surfaces', + language: 'ts', + code: projectShapeConfigCode + } + ] + }, + { + id: 'autodiscovery-rules', + title: 'Use autodiscovery deliberately, and disable it explicitly when you mean it', + bullets: [ + 'Omit `files.routes` when the default `src/routes` location is already the right fit.', + 'Use an explicit `files.routes` object when the route root or prefix should be obvious in config review.', + 'Set `files.routes: false` when the package should not use file-route discovery at all.', + 'Set `files.transport: null` when you want transport autodiscovery disabled instead of guessed.', + 'Use explicit file or glob paths when the project layout is non-standard enough that the default convention would hide intent.' + ], + callouts: [ + { + tone: 'warning', + title: 'Conventions are only helpful when they still describe the project accurately', + body: [ + 'As soon as a default convention stops being obvious, move back to explicit config. That is usually the more maintainable choice.' + ] + } + ] + }, + { + id: 'next-reads', + title: 'Open the deeper page for the shape you just introduced', + cards: [ + { + label: 'Architecture', + title: 'Need the broader package setup map?', + body: 'Open project architecture when the question is the full package layout โ€” authored config, runtime files, generated output, hosted app files, or monorepo boundaries.', + href: docsLink('project-architecture') + }, + { + label: 'Routing', + title: 'Need route modules?', + body: 'Open the HTTP routing page when `files.routes` becomes part of the project shape.', + href: docsLink('http-routing') + }, + { + label: 'Runtime', + title: 'Need transport?', + body: 'Read the transport page when a custom transport file becomes part of the contract between worker code and stateful surfaces.', + href: docsLink('transport-file') + }, + { + label: 'Configuration', + title: 'Need generated env types?', + body: 'Open the generated types page when bindings, Durable Objects, or named entrypoints become part of the package contract.', + href: docsLink('generated-types') + }, + { + label: 'Frameworks', + title: 'Need a host shell?', + body: 'Open the framework pages only when the package truly becomes a Vite or SvelteKit app instead of a worker-first package.', + href: docsLink('vite-standalone') + } + ] + } + ] + }, + { + slug: 'config-environments', + group: 'Devflare', + navTitle: 'Environments', + readTime: '5 min read', + eyebrow: 'Configuration', + title: + 'Use `config.env` overlays to change only what differs between local, preview, and production', + summary: + 'Keep one base config, layer environment-specific overrides with `config.env`, and let Devflare resolve preview or production details only in the commands that actually need them.', + description: + 'Devflare environments are an overlay system, not a second copy of the whole config file. The base config should hold the stable project story, and `config.env` should only override the parts that genuinely differ by environment.', + highlights: [ + '`config.env` is merged onto the base config instead of replacing it wholesale.', + 'Environment overlays can change bindings, vars, files, limits, observability, build settings, and Wrangler passthrough without duplicating the whole config.', + 'Explicit preview and production deploy targets already pin their environment, so `--env` is most useful on config-inspection and build-style commands.', + 'When previews need their own disposable infrastructure, pair `config.env.preview` with `preview.scope()` instead of pointing preview traffic at production resource names.', + 'Keep `.env`, `vars`, and `secrets` in separate roles so config-time inputs and runtime bindings do not blur together.' + ], + facts: [ + { + label: 'Best for', + value: 'Projects that need different bindings or runtime behavior in preview and production' + }, + { + label: 'Merge model', + value: + 'Base config first, then `config.env[name]`, then preview materialization when relevant' + }, + { label: 'Main habit', value: 'Repeat only the keys that actually differ by environment' } + ], + sourcePages: [ + 'packages/devflare/src/config/schema.ts', + 'schema-env.ts', + 'resolve.ts' + ], + sections: [ + { + id: 'merge-model', + title: 'Keep one base config and let the overlay change only the deltas', + paragraphs: [ + 'The main config should describe the stable project: the worker name, the usual file surfaces, and the bindings or defaults that exist regardless of environment. `config.env` is where you change only the parts that diverge for preview, production, or another named lane.', + 'The overlay model feels more predictable than copying whole config files around. The shared story stays in one place, while the environment-specific differences stay small enough to review accurately.' + ], + snippets: [ + { + title: 'Use `config.env` for targeted overrides instead of a second full config', + language: 'ts', + code: environmentOverlayCode + } + ], + callouts: [ + { + tone: 'success', + title: 'A smaller overlay is usually a better overlay', + body: [ + 'If an environment block starts to repeat most of the base config, that is usually a sign the base config should be refactored instead of duplicated.' + ] + } + ] + }, + { + id: 'what-can-change', + title: 'Know what environment overlays are actually allowed to change', + table: { + headers: ['Override lane', 'Typical reason to change it'], + rows: [ + [ + '`name`, compatibility settings', + 'The environment truly needs a different runtime identity or compatibility posture.' + ], + [ + '`files`, `bindings`, `triggers`', + 'Preview or production uses different surfaces, resources, or schedules.' + ], + [ + '`vars`, `secrets`', + 'Runtime strings or secret-binding declarations differ by environment.' + ], + [ + '`routes`, `assets`, `limits`, `observability`', + 'Deployment routing, static assets, CPU limits, or observability should differ by lane.' + ], + [ + '`rolldown`, `vite`, `wrangler`', + 'The build host or the passthrough escape hatch needs environment-specific behavior.' + ] + ] + }, + paragraphs: [ + 'This is why `config.env` is more than a raw Wrangler mirror. It can change the Devflare-owned parts of the project too, as long as those differences are still part of the same package story.' + ] + }, + { + id: 'override-merge-rules', + title: 'Environment overrides: arrays replace, objects deep-merge, primitives replace', + paragraphs: [ + 'Overlays compose onto the base config with three rules: object-shaped values are deep-merged key by key, primitive values (strings, numbers, booleans) are replaced wholesale, and array-shaped values are replaced wholesale (they do not append). Reading an environment block as an override of the base โ€” not as an addition to it โ€” keeps these rules predictable.', + 'The replace-arrays rule is the one most likely to surprise someone arriving from a config system that appended arrays. If a base config sets `routes: [โ€ฆ]` and the overlay sets `routes: [โ€ฆ]`, the overlayโ€™s array becomes the resolved value; the base array is not concatenated. The same applies to `migrations` and to nested arrays like `triggers.crons`.' + ], + table: { + headers: ['Field shape', 'Merge rule', 'Example'], + rows: [ + [ + '`routes` (array)', + 'Replace', + 'Base `routes: [{ pattern: "app.example.com/*", zone_name: "example.com" }]` + overlay `routes: [{ pattern: "preview.example.com/*", zone_name: "example.com" }]` resolves to **only** the preview entry.' + ], + [ + '`migrations` (array)', + 'Replace', + 'Base `migrations: [{ tag: "v1", new_classes: ["Room"] }]` + overlay `migrations: [{ tag: "v2", new_classes: ["Room", "User"] }]` resolves to **only** the v2 entry. To preserve history, restate the prior migrations in the overlay.' + ], + [ + '`triggers.crons` (array under nested object)', + 'Replace at the array level (the parent `triggers` object is still deep-merged)', + 'Base `triggers: { crons: ["*/5 * * * *"] }` + overlay `triggers: { crons: ["0 * * * *"] }` resolves to `triggers.crons = ["0 * * * *"]`. Other keys on `triggers` deep-merge as usual.' + ], + [ + '`bindings` (object)', + 'Deep-merge', + 'Adding `bindings.kv.NEW_NS` in an overlay extends the base `bindings.kv` map; existing namespaces survive unless the overlay names the same key.' + ], + [ + '`name`, `compatibility_date` (primitive)', + 'Replace', + 'The overlay value wins when present; otherwise the base value stays.' + ] + ] + }, + callouts: [ + { + tone: 'warning', + title: 'Arrays replace, they do not append', + body: [ + 'If you only want to add one extra route, one extra cron, or one extra migration to the base, the overlay must restate the base entries alongside the new one. An overlay that lists only the new entry will silently drop the base entries from the resolved config.' + ] + } + ] + }, + { + id: 'when-to-pick-env', + title: + 'Choose the environment where it matters, and let explicit deploy targets do the rest', + steps: [ + 'Use commands like `devflare config --env ` or `devflare build --env ` when you want to inspect or compile one named environment intentionally.', + 'Let explicit preview deploys target the preview environment instead of also layering on an unrelated `--env` decision.', + 'Let explicit production deploys stay pinned to production so the deployment target is never ambiguous.', + 'Keep preview-only resource naming and preview lifecycle behavior inside the preview lane instead of leaking it into the base config.' + ], + callouts: [ + { + tone: 'info', + title: 'Environment choice and deploy target are related, but not identical', + body: [ + '`--env` chooses a config overlay for commands that resolve config environments. Explicit preview and production deploy flags choose the deployment destination itself.' + ] + } + ] + }, + { + id: 'vars-secrets-env', + title: 'Keep `.env`, `vars`, and `secrets` in separate jobs', + bullets: [ + 'Use `.env` and `.env.dev` for config-time inputs. Devflare reads those files itself from the config directory upward, with closer files winning and `.env` overriding `.env.dev` in the same directory.', + 'Use `vars` for values that should compile into Worker-facing output, including nested typed values produced by `env.NAME` descriptors.', + 'Use `secrets` to declare runtime secret binding names, not to store those secret values in config. Today that is mostly schema and type metadata: the schema accepts `{ required: false }`, but generated env typing still treats declared secrets as present and Devflare does not currently turn that flag into a separate deploy-time guarantee.', + 'Use `.env.example` to document config-time inputs for the team instead of leaving those values to memory or chat scrollback.' + ], + callouts: [ + { + tone: 'warning', + title: 'Do not let every string become an environment variable by reflex', + body: [ + 'Stable infrastructure names and intentional runtime strings usually belong in authored config. Save secrets for the values that are actually secret.' + ] + } + ] + } + ] + }, + { + slug: 'typed-env-vars', + group: 'Devflare', + navTitle: 'Typed env vars', + readTime: '6 min read', + eyebrow: 'Configuration', + title: + 'Resolve `.env` values through typed config vars instead of scattering process env reads', + summary: + 'Use `env.NAME` descriptors inside `defineConfig({ vars })`, parse or default them in config, and read the resulting typed values at runtime with `import { vars } from "devflare"`.', + description: + 'Devflare vars can now be a typed bridge from local `.env` files into Worker runtime code. The config owns which variables are required, optional, parsed, or dev-only, while application code reads the resolved shape through the `vars` runtime helper.', + highlights: [ + '`env.EXAMPLE` reads the exact `EXAMPLE=...` name from Devflare-loaded `.env` files or `process.env`.', + 'Variables are required by default; build fails when required values are missing.', + 'Dev mode reports missing values and waits for `.env` / `.env.dev` changes instead of exiting immediately.', + 'Nested objects are preserved, so `vars.mongo.database` is a normal typed runtime access.', + 'Parsers, optional values, normal defaults, and dev-only defaults are all chainable.' + ], + facts: [ + { label: 'Config import', value: "`import { defineConfig, env } from 'devflare/config'`" }, + { label: 'Runtime import', value: "`import { vars } from 'devflare'`" }, + { label: 'File order', value: 'Parents first, then closer directories; `.env.dev` first, `.env` last' }, + { label: 'Missing build vars', value: 'Build fails with a nested missing-variable report' } + ], + sourcePages: [ + 'packages/devflare/src/config/env-vars.ts', + 'packages/devflare/src/config/loader.ts', + 'packages/devflare/src/runtime/exports.ts', + 'packages/devflare/src/cli/commands/type-generation/generator.ts', + 'packages/devflare/tests/unit/config/env-vars.test.ts' + ], + sections: [ + { + id: 'config-shape', + title: 'Declare the runtime shape in config', + paragraphs: [ + 'The `env` export from `devflare/config` does not read the variable immediately. It creates a descriptor that Devflare resolves when it starts dev, builds artifacts, or prints a phase-resolved config.', + 'That keeps config import cheap and lets Devflare report every missing variable at once, using the nested path from `vars` instead of a generic process-env crash.' + ], + snippets: [ + { + title: 'Nested vars with required, optional, parsed, defaulted, and dev-only values', + language: 'ts', + code: typedEnvVarsConfigCode + } + ] + }, + { + id: 'runtime-access', + title: 'Read resolved values through the runtime `vars` helper', + paragraphs: [ + 'At runtime, Devflare exposes the resolved values on the Worker environment and through the `vars` helper. The helper is typed from `devflare types`, so parser return values and nested objects stay visible to TypeScript.', + 'Unparsed environment descriptors resolve to strings. Parsed descriptors use the parser return type, defaults contribute their value type, and optional descriptors become optional properties.' + ], + snippets: [ + { + title: 'Runtime code can use the nested shape directly', + language: 'ts', + filename: 'src/fetch.ts', + code: typedEnvVarsRuntimeCode + } + ] + }, + { + id: 'dotenv-loading', + title: 'Let Devflare parse `.env` files itself', + paragraphs: [ + 'Devflare reads `.env.dev` and `.env` from the config directory and every parent directory. Parent files load first, then closer files override them. Within one directory, `.env.dev` loads first and `.env` wins last.', + 'The parser does not expand `$OTHER_VARIABLE` references. Values such as passwords, MongoDB connection strings, and shell-looking fragments are read as written instead of being interpreted by Bun.' + ], + snippets: [ + { + title: 'The later `.env` value overrides the earlier `.env.dev` value', + filename: '.env', + language: 'dotenv', + code: typedEnvVarsDotenvCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Process env still wins over files', + body: [ + 'CI-provided environment variables and explicit shell exports override `.env` file values. Dotenv files fill in missing process variables; they do not stomp values the process already had.' + ] + } + ] + }, + { + id: 'missing-values', + title: 'Missing required values fail build and pause dev', + paragraphs: [ + 'Required is the default because a config variable usually means the Worker cannot run honestly without that value. Build and config-inspection commands fail with a grouped report that points at the nested `vars` path and the missing environment variable name.', + 'Dev mode is gentler. It prints the same report, waits for `.env` or `.env.dev` to change, and then retries startup. That makes the local loop fixable without restarting the command.' + ], + snippets: [ + { + title: 'Missing variables are grouped by the config path that required them', + language: 'text', + filename: 'missing-env-vars.txt', + code: String.raw`These environment variables are missing: + + secret: SECRET + mongo: + uri: MONGOURI` + } + ] + }, + { + id: 'chainable-helpers', + title: 'Use helpers to make intent explicit', + table: { + headers: ['Helper', 'Meaning', 'Example'], + rows: [ + ['`env.NAME`', 'Required string value.', '`env.SECRET`'], + ['`.optional()`', 'Missing value is allowed and omitted.', '`env.OPTIONAL_LABEL.optional()`'], + ['`.parse(fn)` / `.parser(fn)`', 'Transform the string from env files into a typed runtime value.', '`env.RETRIES.parse(Number)`'], + ['`.default(value)`', 'Use a fallback in every mode when the env value is missing.', "`env.APP_MODE.default('local')`"], + ['`.dev(value)`', 'Use a fallback only in dev when the env value is missing.', '`env.MOCK_TENANT_ID.dev(123)`'] + ] + }, + callouts: [ + { + tone: 'warning', + title: 'Dev-only defaults are still required in build', + body: [ + '`.dev(value)` is intentionally local-only. If the same variable may be missing in build too, use `.default(value)` or `.optional()` instead.' + ] + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/configuration/part-2.ts b/apps/documentation/src/lib/docs/content/configuration/part-2.ts new file mode 100644 index 0000000..fa725f7 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration/part-2.ts @@ -0,0 +1,653 @@ +import type { DocPage } from '../../types' +import { + docsLink, + environmentOverlayCode, + fullConfigExampleCode, + generatedTypesOutputCode, + previewBindingsConfigCode, + previewBindingsLifecycleCode, + projectShapeConfigCode, + runtimeDeploySettingsCode, + workerSurfacesConfigCode +} from './shared' + +export const configurationDocsPart2: DocPage[] = [ + { + slug: 'config-previews', + group: 'Devflare', + navTitle: 'Previews', + readTime: '6 min read', + eyebrow: 'Configuration', + title: 'Author preview-scoped bindings so preview deploys can own disposable infrastructure', + summary: + 'Use `preview.scope()` for bindings that should belong to one preview scope. Devflare materializes names like `notes-db-next`, provisions or reuses the preview-only resources it can manage, and lets you clean them up by the same scope later without touching production resources.', + description: + 'Preview config in Devflare is not only โ€œset `env.preview` and hope for the best.โ€ The extra step is marking the bindings that should belong to a preview deployment. Devflare then materializes those names with a preview identifier, keeps production names separate, and on preview deploys can create or reuse the matching account resources for the binding types it manages locally.', + highlights: [ + '`preview.scope()` marks authored names once; non-preview work resolves back to the base name, while preview deploys materialize a scope such as `preview` or `next` into the binding target.', + 'Plain `--preview` uses the synthetic `preview` identifier, while named `--preview next` or `--scope next` resolves the same config to `*-next` resources.', + 'Preview-scoped resources stay associated with one preview deployment, so the same scope can be inspected and later deleted with `devflare previews cleanup --scope --apply`.', + 'KV, D1, R2, queues, and Vectorize are the main lifecycle-managed preview resource families; other bindings have more specific caveats.', + 'Production databases, buckets, and queues stay out of the blast radius because preview deploys resolve different resource names instead of reusing production names by accident.' + ], + facts: [ + { label: 'Authoring primitive', value: '`preview.scope()` from `devflare/config`' }, + { + label: 'Typical result', + value: '`notes-cache-kv` โ†’ `notes-cache-kv-next` for a `next` preview scope' + }, + { + label: 'Main lifecycle command', + value: '`bunx --bun devflare previews cleanup --scope --apply`' + }, + { + label: 'Best for', + value: + 'Previews that need their own disposable state instead of borrowing production infrastructure' + } + ], + sourcePages: [ + 'README.md', + 'src/config/preview.ts', + 'src/config/preview-resources.ts', + 'src/cli/commands/build-artifacts.ts', + 'src/cli/help-pages/pages/previews.ts', + 'tests/unit/config/preview.test.ts', + 'apps/testing/devflare.config.ts' + ], + sections: [ + { + id: 'mark-preview-owned-bindings', + title: + 'Mark preview-owned bindings in config instead of mutating production names at deploy time', + paragraphs: [ + 'The point of preview-scoped bindings is not to make names look fancy. It is to keep preview infrastructure isolated from production infrastructure while still authoring one readable config.', + '`preview.scope()` returns an opaque marker around the base resource name. Devflare later materializes that marker into a real name for the active preview identifier, which means the authored config can stay stable while preview deploys resolve to preview-owned databases, buckets, queues, and other resources.' + ], + snippets: [ + { + title: 'Author preview-owned bindings once, then let the scope decide the real names', + language: 'ts', + code: previewBindingsConfigCode + } + ], + callouts: [ + { + tone: 'success', + title: 'This is safer than repointing previews at production state', + body: [ + 'When the preview owns a distinct database or queue name, it can be created quickly, reviewed in isolation, and deleted cleanly later. That is much safer than hoping reviewers never touch a production binding in a preview session.' + ] + } + ] + }, + { + id: 'materialization-rules', + title: 'The preview identifier is materialized into the binding target name', + paragraphs: [ + 'In normal local work and non-preview environments, a preview-scoped marker resolves back to the base name. In preview resolution, Devflare inserts the chosen preview identifier using the configured separator, which defaults to `-`.', + 'The identifier order is deliberate: an explicit identifier wins first, then `DEVFLARE_PREVIEW_IDENTIFIER`, then PR or branch-derived env values, and only then the synthetic `preview` fallback for generic preview environments.' + ], + table: { + headers: [ + 'Authored binding target', + 'When it resolves', + 'Resolved name', + 'What that means' + ], + rows: [ + [ + "`pv('notes-cache-kv')`", + 'Local work or non-preview resolution', + '`notes-cache-kv`', + 'The base config stays readable and does not invent preview names unless a preview identifier is actually in play.' + ], + [ + "`pv('notes-cache-kv')`", + 'Plain `--preview` or generic preview environment', + '`notes-cache-kv-preview`', + 'The synthetic `preview` identifier keeps same-worker preview uploads separate from the base resource name.' + ], + [ + "`pv('notes-cache-kv')`", + 'Named preview like `--preview next` or `--scope next`', + '`notes-cache-kv-next`', + 'A named preview scope gets its own clearly-associated resource names and cleanup target.' + ], + [ + "`pv('notes-cache-kv')`", + '`DEVFLARE_PREVIEW_BRANCH=Feature/TeSt-Branch`', + '`notes-cache-kv-feature-test-branch`', + 'Branch-derived identifiers are sanitized into safe resource-name fragments.' + ], + [ + "`preview.scope({ separator: '--' })`", + 'Custom separator plus preview identifier', + '`notes-cache-kv--next`', + 'You can change the separator when the resource naming convention needs it.' + ] + ] + }, + bullets: [ + 'The binding name in `env` stays the same; it is the backing resource target that changes by preview scope.', + 'Production overrides can still point at explicit production resources when production naming should be fully separate from preview naming.', + 'This page is about resource naming and binding targets; preview worker topology is a neighboring decision covered by the preview strategy docs.' + ] + }, + { + id: 'managed-resource-families', + title: 'Some preview-scoped bindings are lifecycle-managed resources, and some are not', + table: { + headers: ['Binding lane', 'Preview naming story', 'Lifecycle behavior'], + rows: [ + [ + 'KV, D1, and R2', + 'Author the resource name with `preview.scope()`.', + 'Preview deploys can create or reuse the scoped resource, and cleanup can delete it later by the same scope.' + ], + [ + 'Queues and DLQs', + 'Producer, consumer, and dead-letter queue names can all be scoped.', + 'Preview deploys can provision the queue resources and cleanup can remove them together.' + ], + [ + 'Vectorize', + 'Index names can be preview-scoped too.', + 'Devflare can provision the preview index shape from the base index metadata and delete it during cleanup later.' + ], + [ + 'Hyperdrive', + 'Names can be materialized for preview scopes.', + 'Devflare does not auto-clone stored credentials, so it warns and can fall back to the base Hyperdrive binding when the preview config does not already exist.' + ], + [ + 'Analytics Engine and Browser Rendering', + 'Dataset or binding names can be materialized.', + 'Devflare reports warnings instead of provisioning or deleting account resources because those families do not follow the same managed lifecycle.' + ], + [ + 'Service bindings, Durable Objects, and routes on dedicated preview workers', + 'Isolation follows preview worker names and ownership more than account resource naming.', + 'Deleting dedicated preview worker scripts also removes preview-only service bindings, Durable Object bindings, and routes attached only to those workers.' + ] + ] + }, + callouts: [ + { + tone: 'warning', + title: 'Preview-scoped does not automatically mean Devflare can provision everything', + body: [ + 'Hyperdrive, Analytics Engine, and Browser Rendering each have their own lifecycle caveats. Devflare says that out loud instead of pretending every binding behaves like KV or D1.' + ] + } + ] + }, + { + id: 'deploy-inspect-cleanup', + title: 'The good preview loop is deploy, inspect, and clean up by the same scope', + paragraphs: [ + 'Preview-scoped bindings work best when the scope stays explicit from deploy through cleanup. The preview deploy resolves the config to preview-owned names, the binding inspection command shows exactly what that scope points at, and cleanup removes the same preview-only resources later.', + 'That is what keeps previews fast to create and safe to tear down. The preview owns its own binding targets, so deleting it does not mean touching production databases or buckets just because the app used the same binding names in code.' + ], + steps: [ + 'Author preview-owned bindings with `preview.scope()` in the main config.', + 'Deploy the preview with an explicit scope such as `--preview next` when the resource names should map to one known preview deployment.', + 'Inspect that scope with `devflare previews bindings --scope next` when you want the resolved targets and worker associations spelled out clearly.', + 'Clean up the same preview later with `devflare previews cleanup --scope next --apply`.' + ], + snippets: [ + { + title: 'One scope in, the same scope back out', + language: 'bash', + code: previewBindingsLifecycleCode + } + ], + cards: [ + { + label: 'Configuration', + title: 'Need the overlay story too?', + body: 'Open the environments page when the question is which config lanes differ by preview or production beyond resource naming.', + href: docsLink('config-environments') + }, + { + label: 'Ship & operate', + title: 'Need the preview topology decision?', + body: 'Open the preview strategy page when the real question is same-worker uploads versus branch-scoped worker families.', + href: docsLink('preview-strategies') + }, + { + label: 'Ship & operate', + title: 'Need lifecycle and cleanup commands?', + body: 'Open preview operations when the question moves from authoring config to registry inspection or cleanup policy.', + href: docsLink('preview-operations') + } + ] + } + ] + }, + { + slug: 'worker-surfaces', + group: 'Devflare', + navTitle: 'Worker surfaces', + readTime: '6 min read', + eyebrow: 'Configuration', + title: + 'Treat fetch, queue, scheduled, and email handlers as separate Worker surfaces with their own files', + summary: + 'Devflare can compose or wrap several Worker surfaces into one generated entrypoint, but the authored source of truth should stay in explicit files such as `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, and `src/email.ts`.', + description: + 'A single Devflare package can own more than one Cloudflare event surface. Keep each surface in its own file when the package genuinely owns that event type, wire schedules through `triggers.crons`, and let the generated composed entrypoint stay generated instead of hand-maintained.', + highlights: [ + 'The conventional event-surface files are `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, and `src/email.ts`.', + 'Use `false` to disable event-surface autodiscovery explicitly when one of those conventions should stay off.', + '`triggers.crons` describes scheduled intent, while `previews.includeCrons` decides whether branch-scoped preview deploys keep cron triggers active or omit them to avoid shared-schedule conflicts.', + 'Devflare can compose or wrap worker surfaces into `.devflare/worker-entrypoints/main.ts` when the runtime shape needs it, but that file remains generated output, not authored source.' + ], + facts: [ + { label: 'Best for', value: 'Packages that own both HTTP and background event surfaces' }, + { + label: 'Default files', + value: '`src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, `src/email.ts`' + }, + { + label: 'Generated output', + value: + '`.devflare/worker-entrypoints/main.ts` when Devflare needs to wrap or compose the worker surfaces it discovered' + }, + { label: 'Test helpers', value: '`cf.worker`, `cf.queue`, `cf.scheduled`, and `cf.email`' } + ], + sourcePages: [ + 'src/worker-entry/surface-paths.ts', + 'src/worker-entry/composed-worker.ts', + 'src/dev-server/worker-surface-paths.ts', + 'src/dev-server/worker-source-watcher.ts', + 'src/cli/help-pages/pages/core.ts', + 'packages/devflare/src/test/simple-context.ts' + ], + sections: [ + { + id: 'surface-map', + title: 'Keep each event surface in its own lane', + paragraphs: [ + 'Devflare does not flatten every Cloudflare event into one mystery handler. When one package owns HTTP, queue consumption, cron jobs, or inbound email, the cleanest shape is usually one file per surface so ownership stays obvious in code review.', + 'That separation is especially useful once the package has both request/response code and background work. The HTTP story stays in fetch or routes, while queue, scheduled, and email code can evolve without disappearing into one huge entry file.' + ], + table: { + headers: ['Surface', 'Conventional file', 'Use it when', 'Helper'], + rows: [ + [ + 'Fetch', + '`src/fetch.ts` or `src/routes/**`', + 'HTTP requests belong to one main handler or route tree.', + '`cf.worker.get()` / `cf.worker.fetch()`' + ], + [ + 'Queue consumer', + '`src/queue.ts`', + 'The package owns deferred, batched, or retryable queue work.', + '`cf.queue.trigger()`' + ], + [ + 'Scheduled handler', + '`src/scheduled.ts` plus `triggers.crons`', + 'Time-based jobs should run from config-owned schedules.', + '`cf.scheduled.trigger()`' + ], + [ + 'Email handler', + '`src/email.ts`', + 'The Worker handles inbound email or local email-handler flows.', + '`cf.email.send()`' + ] + ] + } + }, + { + id: 'scheduled-intent', + title: 'Put scheduled intent in config instead of scripts or comments', + paragraphs: [ + 'A scheduled handler is only half the story. The code lives in `src/scheduled.ts`, but the timing contract belongs in `triggers.crons` so the package declares when the job should run instead of relying on external shell memory.', + 'Preview behavior belongs in config too. `previews.includeCrons` defaults to `false`, so branch-scoped preview deploys drop cron triggers unless you opt them back in deliberately.' + ], + snippets: [ + { + title: 'A package that owns several Worker surfaces explicitly', + language: 'ts', + code: workerSurfacesConfigCode + } + ], + callouts: [ + { + tone: 'warning', + title: 'Preview environments should not inherit cron behavior by accident', + body: [ + 'If previews should run scheduled jobs, say so explicitly. Otherwise keep preview validation focused on the surfaces reviewers actually expect to exercise.' + ] + } + ] + }, + { + id: 'disable-and-compose', + title: 'Disable unused conventions explicitly and let Devflare compose the rest', + paragraphs: [ + 'Generated composition is not only a build detail. The local dev server also uses the same surface model to decide what to watch, so the directories around configured or conventional fetch, queue, scheduled, email, route, and transport files all become reload roots.', + 'That split is intentional: config-file edits take the config reload path, while worker-source changes under those watched roots take the worker reload path. You do not need a second watch system just because the package grew another surface.' + ], + bullets: [ + 'Set `files.queue: false`, `files.scheduled: false`, or `files.email: false` when one of the default conventions should stay off.', + 'Set `files.routes: false` when the package should stay fetch-only instead of discovering a route tree.', + 'When a fetch entry, route tree, or background surface set needs wrapper glue, Devflare can generate a composed entrypoint under `.devflare/worker-entrypoints/main.ts` to fan them into the Worker runtime correctly.', + 'If `wrangler.passthrough.main` is set, or the fetch worker already lives at `assets.directory/_worker.js`, Devflare skips that generated main entry and uses the explicit worker instead.', + 'Generated entrypoints are supposed to churn as the surface set changes. Keep the authored files and config authoritative, and let the glue stay disposable.', + 'Treat that generated entrypoint as output. The authored source of truth remains the explicit files and config that selected them.' + ], + callouts: [ + { + tone: 'info', + title: 'Dev reload follows the same surface roots', + body: [ + 'Worker-source changes under the watched fetch, queue, scheduled, email, route, or transport roots trigger the worker reload path, while edits to the resolved `devflare.config.*` trigger the config reload path instead.' + ] + }, + { + tone: 'info', + title: 'Tail is still a special case', + body: [ + 'Devflare can exercise tail behavior in the test harness when `src/tail.ts` exists, but there is not yet a public `files.tail` config key. Keep the main project-shape story centered on the documented event surfaces, and open the `createTestContext()` page when the question is tail testing.' + ] + } + ] + }, + { + id: 'adjacent-discovery', + title: 'Some nearby `files.*` keys are discovery globs, not event handlers', + paragraphs: [ + 'Not every `files.*` key means โ€œCloudflare will call this file as an event surface.โ€ Some keys tell Devflare where to discover related program structure such as Durable Object classes, named entrypoints, workflow definitions, or transport hooks.', + 'That distinction matters because it keeps code review honest. Event surfaces answer โ€œwhat can invoke this package?โ€, while discovery globs answer โ€œwhat else should Devflare scan and bundle for the runtime contract?โ€' + ], + table: { + headers: ['Config key', 'What it points at', 'Why it is different'], + rows: [ + [ + '`files.durableObjects`', + 'Durable Object class files or globs', + 'These classes are discovered and wrapped; they are not a standalone top-level event surface like fetch or queue.' + ], + [ + '`files.entrypoints`', + 'Named entrypoint files or globs', + 'These support typed cross-worker references and discovery, not a separate Cloudflare event hook.' + ], + [ + '`files.workflows`', + 'Workflow definition files or globs', + 'These are additional discovered modules, not a direct replacement for fetch, queue, scheduled, or email handlers.' + ], + [ + '`files.transport`', + 'One custom transport file', + 'This is a serialization hook for bridge-backed calls, not an event handler that Cloudflare dispatches directly.' + ] + ] + }, + cards: [ + { + label: 'Runtime', + title: 'Need transport behavior?', + body: 'Open the transport page when a discovered transport file becomes part of the package contract.', + href: docsLink('transport-file') + }, + { + label: 'Configuration', + title: 'Need the generated type contract?', + body: 'Open the generated types page when `files.entrypoints`, `ref()`, or discovered Durable Objects need to show up correctly in `env.d.ts`.', + href: docsLink('generated-types') + }, + { + label: 'Configuration', + title: 'Need the broader config map?', + body: 'The runtime and deploy settings page covers the non-surface knobs such as account context, compatibility posture, routes, assets, limits, and migrations.', + href: docsLink('runtime-deploy-settings') + } + ] + } + ] + }, + { + slug: 'generated-types', + group: 'Devflare', + navTitle: 'Generated types', + readTime: '6 min read', + eyebrow: 'Configuration', + title: + 'Use `devflare types` to keep `env.d.ts` and `Entrypoints` aligned with the project you actually authored', + summary: + '`devflare types` turns config, discovered Durable Objects, named entrypoints, and cross-worker references into one generated TypeScript contract instead of a pile of hand-maintained env guesswork.', + description: + 'The generated file is more than editor garnish. It is the typed mirror of your Devflare config and discovery rules: bindings land on global `DevflareEnv`, named entrypoints become an exported `Entrypoints` union, and referenced workers can produce typed service interfaces when Devflare can follow them accurately.', + highlights: [ + '`devflare types` writes `env.d.ts` relative to the current working directory by default, or another path when you pass `--output`.', + 'Bindings, vars, secrets, Durable Objects, service bindings, and named entrypoints all feed the generated contract.', + '`Entrypoints` exists so `defineConfig()` and later `ref().worker(...)` calls can stay type-safe.', + 'When Devflare cannot derive a concrete service interface, it falls back to `Fetcher` instead of pretending it knows more than it does.' + ], + facts: [ + { + label: 'Best for', + value: + 'Packages that use bindings, Durable Objects, service bindings, or named worker entrypoints' + }, + { label: 'Main command', value: '`bunx --bun devflare types`' }, + { + label: 'Default output', + value: + '`env.d.ts` relative to the directory you run the command from unless you override it' + }, + { + label: 'Best pairing', + value: "`defineConfig()` on the referenced worker config" + } + ], + sourcePages: [ + 'README.md', + 'src/cli/help-pages/pages/core.ts', + 'src/cli/commands/types.ts', + 'src/cli/commands/type-generation/generator.ts', + 'src/config/define.ts', + 'src/config/ref.ts', + 'src/utils/entrypoint-discovery.ts', + 'cases/case5/devflare.config.ts', + 'cases/case5/math-service/devflare.config.ts' + ], + sections: [ + { + id: 'generated-contract', + title: 'Treat the generated file as the typed contract, not as handwritten glue', + paragraphs: [ + '`devflare types` reads the resolved config, discovers supporting source files, and writes one generated file that says what the package runtime actually exposes. This is more reliable than hand-maintained `env` declarations because the source of truth stays in config and file discovery, not in a second hand-maintained type file.', + 'The result is usually a global `DevflareEnv` interface plus an exported `Entrypoints` union. That combination is what keeps bindings, cross-worker service calls, and named entrypoints typed without making you manually mirror every config change.' + ], + snippets: [ + { + title: 'A generated file should read like output, not a second config file', + filename: 'env.d.ts', + language: 'ts', + code: generatedTypesOutputCode + }, + { + title: 'The command loop stays intentionally small', + language: 'bash', + code: String.raw`bunx --bun devflare types +bunx --bun devflare types --output env.generated.d.ts` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Generated means generated', + body: [ + 'Do not hand-edit `env.d.ts` and expect the next run to preserve it. Change config or source files, then rerun `devflare types`.' + ] + } + ] + }, + { + id: 'what-devflare-discovers', + title: 'Know what the command is actually discovering', + table: { + headers: ['Input Devflare reads', 'Where it comes from', 'Typed result'], + rows: [ + [ + '`bindings`, `vars`, and `secrets`', + 'The resolved top-level `devflare.config.*` from the current working directory or explicit `--config` path.', + 'Members on global `DevflareEnv`.' + ], + [ + 'Local Durable Object classes', + '`files.durableObjects` or the default `**/do.*.{ts,js}` discovery pattern.', + '`DurableObjectNamespace<...>` when the class can be located accurately.' + ], + [ + 'Named worker entrypoints', + '`files.entrypoints` or the default `**/ep.*.{ts,js}` discovery pattern plus exported classes extending `WorkerEntrypoint`.', + 'An exported `Entrypoints` union for `defineConfig()`.' + ], + [ + '`ref()` references', + 'Imported Devflare configs in other packages or subfolders.', + 'Typed service bindings and cross-worker Durable Object namespaces when Devflare can resolve them.' + ], + [ + 'Unknown or unresolvable service surface', + 'A target worker or entrypoint that cannot be turned into a stable interface.', + '`Fetcher` fallback instead of fake precision.' + ] + ] + }, + bullets: [ + 'If no named entrypoints are discovered yet, `Entrypoints` stays `string` โ€” the fallback is intentional.', + '`devflare types` does not take an `--env` flag today, so the generated contract reflects the resolved base config rather than a named environment overlay.', + 'If you choose a nested `--output` path, create the parent directory first; the command writes the file but does not scaffold missing folders for you.', + 'Discovery follows the configured file patterns first, then falls back to the default Durable Object and entrypoint globs.', + 'The generated types are only as good as the authored config and file naming conventions they can see.' + ], + callouts: [ + { + tone: 'info', + title: 'Typed fallback is still honest typing', + body: [ + 'Getting `Fetcher` for a service binding is not a failure of the generator so much as Devflare refusing to invent a stronger interface than it can justify from the available source.' + ] + } + ] + }, + { + id: 'typed-entrypoints', + title: 'Type the worker that owns the entrypoints, then let `ref()` carry that knowledge', + paragraphs: [ + "The `Entrypoints` union matters most on the worker being referenced. Import that generated type into the worker's own config and pass it to `defineConfig()`, then callers that use `ref(() => import(...))` can ask for named entrypoints without turning those names into loose string conventions.", + "That keeps the typing relationship honest: the worker that owns `ep.*.ts` files declares which entrypoints exist, and the worker that consumes them gets autocomplete and checking through `ref().worker('...')` later." + ], + snippets: [ + { + title: 'One worker declares the entrypoints, another consumes them through `ref()`', + activeFile: 'devflare.config.ts', + structure: [ + { path: 'math-service', kind: 'folder' }, + { path: 'math-service/ep.admin.ts' }, + { path: 'math-service/devflare.config.ts' }, + { path: 'devflare.config.ts' } + ], + files: [ + { + path: 'math-service/ep.admin.ts', + language: 'ts', + code: String.raw`import { WorkerEntrypoint } from 'cloudflare:workers' + +export class AdminEntrypoint extends WorkerEntrypoint { + async resetStats(): Promise<{ success: boolean }> { + return { success: true } + } +}` + }, + { + path: 'math-service/devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' +import type { Entrypoints } from './env' + +export default defineConfig({ + name: 'math-worker', + files: { + fetch: 'worker.ts' + } +})` + }, + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig, ref } from 'devflare/config' + +const mathWorker = ref(() => import('./math-service/devflare.config')) + +export default defineConfig({ + name: 'case5-gateway', + bindings: { + services: { + MATH_SERVICE: mathWorker.worker, + ADMIN: mathWorker.worker('AdminEntrypoint') + } + } +})` + } + ] + } + ], + bullets: [ + 'Put `defineConfig()` on the referenced worker config, not on every caller in the repo by reflex.', + 'Keep the named entrypoint files boring and explicit: `ep.*.ts` plus classes extending `WorkerEntrypoint`.', + 'Rerun `devflare types` in the worker that owns those entrypoints whenever you rename a class or add another one.' + ], + callouts: [ + { + tone: 'warning', + title: 'Types are not a substitute for critical deploy validation', + body: [ + 'Named service entrypoints are modeled at the Devflare layer, but if a particular service path is operationally critical, still inspect the compiled output with `devflare build` or `devflare config print --format wrangler` before trusting muscle memory.' + ] + } + ] + }, + { + id: 'habits', + title: 'Keep the generated contract boring and rerunnable', + steps: [ + 'Run `devflare types` after adding or renaming bindings, Durable Objects, service references, or named entrypoints.', + 'Keep the default cwd-relative `env.d.ts` location unless a custom `--output` path truly buys something more than folder aesthetics.', + 'Import `Entrypoints` from the generated file only where the owning worker config needs it.', + 'Inspect compiled output when a cross-worker or entrypoint boundary matters operationally, not just ergonomically in the editor.' + ], + cards: [ + { + label: 'Bindings', + title: 'Need the multi-worker architecture story?', + body: 'Open the multi-worker page when the question is whether another worker boundary is warranted before you worry about typing that boundary.', + href: docsLink('multi-workers') + }, + { + label: 'Configuration', + title: 'Need the surface-discovery map?', + body: 'The worker-surfaces page explains which authored files and discovery globs become part of the worker contract in the first place.', + href: docsLink('worker-surfaces') + }, + { + label: 'CLI', + title: 'Need the broader command map?', + body: 'The CLI page keeps `types`, `build`, `deploy`, `doctor`, and config-inspection commands in one everyday workflow map.', + href: docsLink('devflare-cli') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/configuration/part-3.ts b/apps/documentation/src/lib/docs/content/configuration/part-3.ts new file mode 100644 index 0000000..fa540da --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration/part-3.ts @@ -0,0 +1,289 @@ +import type { DocPage } from '../../types' +import { + customDomainRouteCode, + docsLink, + environmentOverlayCode, + fullConfigExampleCode, + generatedTypesOutputCode, + previewBindingsConfigCode, + previewBindingsLifecycleCode, + projectShapeConfigCode, + runtimeDeploySettingsCode, + workerSurfacesConfigCode, + workersDevRouteCode, + workersRouteCode +} from './shared' + +export const configurationDocsPart3: DocPage[] = [ + { + slug: 'runtime-deploy-settings', + group: 'Devflare', + navTitle: 'Runtime & deploy settings', + readTime: '7 min read', + eyebrow: 'Configuration', + title: + 'Keep runtime posture and deployment shape in authored config instead of scattered deploy conventions', + summary: + 'Use config for account context, compatibility posture, assets, deployment routes, WebSocket proxy rules, migrations, observability, limits, and preview cron behavior instead of rediscovering those settings in scripts later.', + description: + 'Devflare exposes several config lanes that are not about file discovery at all. These keys shape runtime identity, Cloudflare compatibility, deployment routing, assets, release behavior, and operational posture, so they belong in authored config where the team can review them accurately.', + highlights: [ + '`accountId` matters when remote bindings, name-based resource resolution, or account-aware operations should target one Cloudflare account explicitly.', + '`compatibilityDate` defaults to the current date, and Devflare always includes `nodejs_compat` plus `nodejs_als` in compatibility flags.', + '`assets`, `routes`, and `wsRoutes` shape delivery and dev behavior; they are not the same thing as app routing under `files.routes`.', + '`limits`, `observability`, `migrations`, and `previews.includeCrons` are source-controlled runtime and release knobs; in practice `previews.includeCrons` decides whether branch-scoped preview deploys keep cron triggers.' + ], + facts: [ + { + label: 'Best for', + value: + 'Projects that need explicit runtime posture and delivery shape beyond the basic file surfaces' + }, + { label: 'Forced compatibility flags', value: '`nodejs_compat` and `nodejs_als`' }, + { + label: 'Routing split', + value: + '`files.routes` is app routing, while top-level `routes` is Cloudflare deployment routing' + }, + { label: 'Preview cron default', value: '`previews.includeCrons` defaults to `false`' } + ], + sourcePages: [ + 'src/config/schema.ts', + 'src/config/schema-runtime.ts', + 'src/config/schema-env.ts', + 'src/dev-server/server.ts', + 'src/vite/plugin.ts', + 'https://developers.cloudflare.com/workers/configuration/routing/', + 'https://developers.cloudflare.com/workers/configuration/routing/custom-domains/', + 'https://developers.cloudflare.com/workers/configuration/routing/routes/', + 'https://developers.cloudflare.com/workers/configuration/routing/workers-dev/', + 'https://developers.cloudflare.com/workers/wrangler/configuration/#types-of-routes' + ], + sections: [ + { + id: 'identity-and-compat', + title: 'Set runtime identity and compatibility posture explicitly', + paragraphs: [ + 'Not every package needs the full advanced runtime section on day one, but once remote bindings, compatibility drift, or account-aware operations matter, these settings should move into config instead of living in loose scripts and remembered defaults.', + 'The important habit is that runtime posture should be reviewable in source control. If a package relies on a specific compatibility date or a specific Cloudflare account, that fact should be obvious before the deploy step runs.' + ], + table: { + headers: ['Key', 'Use it when', 'Important behavior'], + rows: [ + [ + '`accountId`', + 'Remote bindings, name-based resource lookup, or account-aware commands should target one Cloudflare account explicitly.', + 'Remote AI and Vectorize flows need a clear account, and config-level `accountId` becomes one resolution lane for account-aware operations and config-driven resource resolution.' + ], + [ + '`compatibilityDate`', + 'The package should pin runtime behavior instead of inheriting date drift.', + 'Devflare defaults it to the current date when you omit it, so explicit pinning is the safer choice once the package is real.' + ], + [ + '`compatibilityFlags`', + 'You need extra Workers compatibility flags beyond the default posture.', + 'Devflare always includes `nodejs_compat` and `nodejs_als`, so custom flags should be deliberate additions instead of copy-by-habit repetition.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'Do not restate the forced flags unless you are making a point', + body: [ + 'Devflare already includes `nodejs_compat` and `nodejs_als`. Keep `compatibilityFlags` focused on the extra posture your package actually needs.' + ] + } + ] + }, + { + id: 'cloudflare-routing-models', + title: 'Choose the Cloudflare endpoint model first', + paragraphs: [ + 'Cloudflare documents three inbound endpoint models for Workers: Custom Domains, normal Workers routes, and the automatic `workers.dev` route. They are not interchangeable, and Devflare keeps that distinction in the top-level `routes` config instead of inventing a second routing vocabulary.', + 'Use a Custom Domain when the Worker is the origin for a whole hostname. Use a normal Workers route when a Worker should sit in front of an existing proxied hostname, match a wildcard host, or match a path prefix. Use `workers.dev` for getting started or preview-style reachability, and disable it when production should only be reachable through your own domain.' + ], + table: { + headers: ['Goal', 'Devflare config shape', 'Cloudflare behavior'], + rows: [ + [ + 'Worker owns every path on one hostname', + '`routes: [{ pattern: "app.example.com", custom_domain: true }]`', + 'Custom Domains match the exact hostname; paths and query strings do not participate in the match.' + ], + [ + 'Worker intercepts a path or wildcard host in a Cloudflare zone', + '`routes: [{ pattern: "app.example.com/api/*", zone_name: "example.com" }]`', + 'Normal routes use route patterns, may include `*`, and the most specific matching route wins.' + ], + [ + 'Worker remains reachable on the account subdomain', + 'Default generated Wrangler config keeps `workers_dev: true`.', + 'Cloudflare assigns `..workers.dev`; Cloudflare recommends routes or custom domains for production.' + ] + ] + }, + snippets: [ + { + title: 'Custom Domain for a Worker-owned hostname', + language: 'ts', + code: customDomainRouteCode + }, + { + title: 'Workers route for path or wildcard matching', + language: 'ts', + code: workersRouteCode + }, + { + title: 'Disable workers.dev when only custom endpoints should serve production', + language: 'ts', + code: workersDevRouteCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Routes can sit in front of Custom Domains', + body: [ + 'Cloudflare treats a Worker on a Custom Domain as an origin. A more specific normal route on the same hostname can run first, then call `fetch(request)` to invoke the Custom Domain Worker behind it.' + ] + }, + { + tone: 'warning', + title: 'Same-zone fetch is different for routes and Custom Domains', + body: [ + 'Cloudflare documents that Custom Domains can be invoked by same-zone `fetch()`, while normal routes cannot be the target of same-zone `fetch()` and should use service bindings for Worker-to-Worker calls.' + ] + } + ] + }, + { + id: 'deploy-shape', + title: 'Keep deployment shape in config, not in app routing or shell scripts', + paragraphs: [ + 'Several config keys answer deployment questions rather than application-routing questions. Keeping those lanes separate is what stops app URLs, Cloudflare routes, and dev-only WebSocket proxy behavior from collapsing into one blurry story.', + 'If the package serves static assets, mounts a custom domain, or proxies Durable Object WebSockets in development, that shape should live in config beside the rest of the deployment contract.', + 'Custom Domains are host-only: use `custom_domain: true` with a bare hostname such as `docs.example.com`. For wildcard or path matching such as `docs.example.com/*` or `docs.example.com/api/*`, use a normal Workers route with `zone_name` or `zone_id` instead.' + ], + table: { + headers: ['Key', 'What it controls', 'Common use'], + rows: [ + [ + '`assets`', + 'Static asset directory plus optional binding name', + 'Point Devflare at one static directory and keep asset delivery visible in source.' + ], + [ + '`routes`', + 'Cloudflare deployment route patterns', + 'Attach the Worker to host or zone patterns at deploy time.' + ], + [ + '`wsRoutes`', + 'Dev-mode Durable Object WebSocket proxy patterns', + 'Forward development WebSocket paths into Durable Object namespaces explicitly.' + ] + ] + }, + snippets: [ + { + title: 'One place for runtime posture and deployment-facing settings', + language: 'ts', + code: runtimeDeploySettingsCode + } + ], + callouts: [ + { + tone: 'warning', + title: 'Top-level `routes` is not the same thing as `files.routes`', + body: [ + '`files.routes` controls your app route tree. Top-level `routes` controls Cloudflare deployment routing. Keep those ideas separate so the package stays reviewable.' + ] + }, + { + tone: 'warning', + title: 'Custom Domains are not wildcard routes', + body: [ + 'Cloudflare Custom Domains match the hostname exactly and ignore paths. Do not add `/*` when `custom_domain: true`; a request to any path on that hostname will already invoke the Worker.' + ] + } + ] + }, + { + id: 'release-controls', + title: 'Put release and operational controls in source control too', + table: { + headers: ['Key', 'Why it exists'], + rows: [ + [ + '`previews.includeCrons`', + 'Choose whether branch-scoped preview deploys keep cron triggers instead of omitting them to avoid shared-schedule conflicts.' + ], + [ + '`limits.cpu_ms`', + 'Declare CPU expectations in config rather than treating them as after-the-fact deploy tuning.' + ], + [ + '`observability.enabled` / `head_sampling_rate`', + 'Keep tracing or sampling posture explicit for the environments that need it.' + ], + [ + '`migrations`', + 'Track Durable Object class lifecycle in the same source-controlled package that owns those classes.' + ] + ] + }, + paragraphs: [ + 'Once a package has Durable Object history, production traffic expectations, or explicit preview behavior, the runtime contract is no longer just โ€œwhat files exist?โ€ It also includes how that package should be migrated, sampled, and limited at runtime.', + 'These settings belong in the same config as the Worker surfaces. They are part of the deployable contract, not just garnish around it.' + ], + callouts: [ + { + tone: 'warning', + title: 'Durable Object migrations still deserve explicit release thinking', + body: [ + 'Keep migrations authored in config and remember that plain preview uploads do not apply Durable Object migrations. If the preview must exercise real Durable Object lifecycle changes, use the preview strategy that matches that reality.' + ] + } + ] + }, + { + id: 'related-pages', + title: 'Open the neighboring page when the setting changes the larger deployment story', + cards: [ + { + label: 'Configuration', + title: 'Need environment overlays?', + body: 'Use the environments page when these settings differ by preview, production, or another named lane.', + href: docsLink('config-environments') + }, + { + label: 'Configuration', + title: 'Need preview-scoped bindings?', + body: 'Open the previews config page when preview deployments should own separate databases, buckets, or queues that can be cleaned up by scope later.', + href: docsLink('config-previews') + }, + { + label: 'Ship & operate', + title: 'Need the production story?', + body: 'The production deploy page covers explicit deploy targets and the inspection tools that belong beside them.', + href: docsLink('production-deploys') + }, + { + label: 'Ship & operate', + title: 'Need preview behavior?', + body: 'Preview strategy docs cover named preview scopes, same-worker uploads, and the Durable Object caveats around them.', + href: docsLink('preview-strategies') + }, + { + label: 'Routing', + title: 'Need app-route shape?', + body: 'Open the routing page when the question is your route tree or request middleware, not Cloudflare deployment routes.', + href: docsLink('http-routing') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/configuration/shared.ts b/apps/documentation/src/lib/docs/content/configuration/shared.ts new file mode 100644 index 0000000..2482185 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/configuration/shared.ts @@ -0,0 +1,430 @@ +๏ปฟimport type { DocPage } from '../../types' + +export const docsLink = (slug: string): string => `/docs/${slug}` + +export const projectShapeConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + durableObjects: 'src/do/**/*.ts', + transport: null + }, + assets: { + directory: 'public' + } +})` + +export const fullConfigExampleCode = String.raw`import { defineConfig, env } from 'devflare/config' + +export default defineConfig({ + name: 'docs-platform', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2026-03-17', + compatibilityFlags: ['urlpattern_polyfill'], + previews: { + includeCrons: false + }, + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts', + entrypoints: 'src/ep/**/*.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + workflows: 'src/workflows/**/*.ts', + transport: 'src/transport.ts' + }, + bindings: { + kv: { + CACHE: 'docs-cache' + }, + d1: { + PRIMARY_DB: 'docs-db' + }, + r2: { + UPLOADS: 'docs-uploads' + }, + durableObjects: { + CHAT_ROOMS: 'ChatRoom' + }, + queues: { + producers: { + EMAILS: 'docs-emails' + }, + consumers: [ + { + queue: 'docs-emails', + deadLetterQueue: 'docs-emails-dlq', + maxBatchSize: 50, + maxBatchTimeout: 10, + maxRetries: 5, + maxConcurrency: 2, + retryDelay: 30 + } + ] + }, + services: { + AUTH: { + service: 'auth-worker' + } + }, + ai: { + binding: 'AI' + }, + vectorize: { + SEARCH_INDEX: { + indexName: 'docs-search' + } + }, + hyperdrive: { + APP_DB: 'docs-primary-db' + }, + browser: { + BROWSER: 'browser' + }, + analyticsEngine: { + REQUESTS: { + dataset: 'docs_requests' + } + }, + sendEmail: { + MAILER: { + destinationAddress: 'team@example.com' + } + } + }, + triggers: { + crons: ['0 */6 * * *'] + }, + vars: { + APP_ENV: 'development', + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE + }, + retries: env.RETRIES.parse(Number) + }, + secrets: { + API_TOKEN: { + required: true + } + }, + routes: [ + { + pattern: 'docs.example.com', + custom_domain: true + } + ], + wsRoutes: [ + { + pattern: '/ws/:id', + doNamespace: 'CHAT_ROOMS', + idParam: 'id', + forwardPath: '/websocket' + } + ], + assets: { + directory: 'static', + binding: 'ASSETS' + }, + limits: { + cpu_ms: 50 + }, + observability: { + enabled: true, + head_sampling_rate: 1 + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ChatRoom'] + } + ], + rolldown: { + target: 'es2022', + minify: true, + sourcemap: true, + options: {} + }, + vite: { + plugins: [] + }, + env: { + preview: { + vars: { + APP_ENV: 'preview' + }, + previews: { + includeCrons: false + }, + observability: { + enabled: true, + head_sampling_rate: 1 + } + }, + production: { + vars: { + APP_ENV: 'production' + } + } + }, + wrangler: { + passthrough: { + logpush: true + } + } +})` + +export const typedEnvVarsConfigCode = String.raw`import { defineConfig, env } from 'devflare/config' + +export default defineConfig({ + name: 'voices-api', + vars: { + secret: env.SECRET, + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE + }, + retries: env.RETRIES.parse(Number), + optionalLabel: env.OPTIONAL_LABEL.optional(), + mode: env.APP_MODE.default('local'), + mockTenantId: env.MOCK_TENANT_ID.dev(123) + } +})` + +export const typedEnvVarsRuntimeCode = String.raw`import { vars } from 'devflare' + +export default { + async fetch() { + return Response.json({ + database: vars.mongo.database, + retries: vars.retries + }) + } +}` + +export const typedEnvVarsDotenvCode = String.raw`# .env.dev +SECRET=local-secret +MONGOURI=mongodb://127.0.0.1:27017 +MONGODATABASE=voices_dev +RETRIES=1 + +# .env +MONGODATABASE=voices` + +export const environmentOverlayCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + CACHE: 'notes-cache' + } + }, + vars: { + APP_ENV: 'local' + }, + env: { + preview: { + bindings: { + kv: { + CACHE: 'notes-preview-cache' + } + }, + vars: { + APP_ENV: 'preview' + }, + previews: { + includeCrons: false + } + }, + production: { + vars: { + APP_ENV: 'production' + }, + observability: { + enabled: true, + head_sampling_rate: 1 + } + } + } +})` + +export const previewBindingsConfigCode = String.raw`import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + name: 'notes-api', + bindings: { + kv: { + CACHE: pv('notes-cache-kv') + }, + d1: { + PRIMARY_DB: pv('notes-db') + }, + r2: { + UPLOADS: pv('notes-uploads-bucket') + }, + queues: { + producers: { + EMAILS: pv('notes-emails-queue') + }, + consumers: [ + { + queue: pv('notes-emails-queue'), + deadLetterQueue: pv('notes-emails-dlq') + } + ] + } + }, + env: { + preview: { + vars: { + APP_ENV: 'preview' + } + }, + production: { + bindings: { + kv: { + CACHE: 'notes-cache-kv-production' + }, + d1: { + PRIMARY_DB: 'notes-db-production' + } + }, + vars: { + APP_ENV: 'production' + } + } + } +})` + +export const previewBindingsLifecycleCode = String.raw`bunx --bun devflare deploy --preview next +bunx --bun devflare previews bindings --scope next +bunx --bun devflare previews cleanup --scope next --apply` + +export const workerSurfacesConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'jobs-worker', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + routes: false + }, + triggers: { + crons: ['0 */6 * * *'] + }, + previews: { + includeCrons: false + } +})` + +export const runtimeDeploySettingsCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'docs-site', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2026-03-17', + assets: { + directory: 'static', + binding: 'ASSETS' + }, + routes: [ + { pattern: 'docs.example.com', custom_domain: true } + ], + wsRoutes: [ + { + pattern: '/ws/:id', + doNamespace: 'CHAT_ROOMS' + } + ], + limits: { + cpu_ms: 50 + }, + observability: { + enabled: true, + head_sampling_rate: 1 + }, + previews: { + includeCrons: false + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ChatRoom'] + } + ] +})` + +export const customDomainRouteCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'app-worker', + routes: [ + { + pattern: 'app.example.com', + custom_domain: true + } + ] +})` + +export const workersRouteCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'api-proxy-worker', + routes: [ + { + pattern: 'app.example.com/api/*', + zone_name: 'example.com' + } + ] +})` + +export const workersDevRouteCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'scratch-worker', + wrangler: { + passthrough: { + workers_dev: false + } + } +})` + +export const generatedTypesOutputCode = String.raw`// Generated by devflare - DO NOT EDIT +// Run devflare types to regenerate + +import type { MathServiceInterface } from '../src/math-service.types' +import type { AdminEntrypointInterface } from '../src/math-service.types' + +declare global { + interface DevflareEnv { + MATH_SERVICE: MathServiceInterface + ADMIN: AdminEntrypointInterface + } +} + +/** + * Named entrypoints discovered from ep.*.ts files. + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = 'AdminEntrypoint'` diff --git a/apps/documentation/src/lib/docs/content/devflare.ts b/apps/documentation/src/lib/docs/content/devflare.ts new file mode 100644 index 0000000..7f61126 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare.ts @@ -0,0 +1 @@ +export { devflareDocs } from './devflare/index' diff --git a/apps/documentation/src/lib/docs/content/devflare/index.ts b/apps/documentation/src/lib/docs/content/devflare/index.ts new file mode 100644 index 0000000..a1ea095 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/index.ts @@ -0,0 +1,11 @@ +import { devflareDocsPart1 } from './part-1' +import { devflareDocsPart2 } from './part-2' +import { devflareDocsPart3 } from './part-3' +import { devflareDocsPart4 } from './part-4' + +export const devflareDocs = [ + devflareDocsPart1, + devflareDocsPart2, + devflareDocsPart3, + devflareDocsPart4 +].flat() diff --git a/apps/documentation/src/lib/docs/content/devflare/part-1.ts b/apps/documentation/src/lib/docs/content/devflare/part-1.ts new file mode 100644 index 0000000..ea4995f --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/part-1.ts @@ -0,0 +1,420 @@ +import type { DocPage } from '../../types' +import { + bindingTestingGuideCards, + bindingTestingGuideRows, + docsLink, + projectArchitectureFullSurfaceConfigCode, + projectArchitectureFullSurfaceDurableObjectCode, + projectArchitectureFullSurfaceQueueCode, + projectArchitectureFullSurfaceStructure, + projectArchitectureHostedAppConfigCode, + projectArchitectureHostedAppPackageCode, + projectArchitectureHostedAppStructure, + projectArchitectureHostedAppViteCode, + projectArchitectureMonorepoCommandsCode, + projectArchitectureMonorepoRootPackageCode, + projectArchitectureMonorepoStructure, + projectArchitectureMonorepoTurboCode, + projectArchitectureStarterConfigCode, + projectArchitectureStarterFetchCode, + projectArchitectureStarterPackageCode, + projectArchitectureStarterRouteCode, + projectArchitectureStarterStructure, + projectArchitectureSveltekitCase18ConfigCode, + testingFeelsNativeConfigCode, + testingFeelsNativeDurableObjectCode, + testingFeelsNativeStructure, + testingFeelsNativeTestCode, + testingFeelsNativeTransportCode, + testingFeelsNativeValueCode +} from './shared' + +export const devflareDocsPart1: DocPage[] = [ + { + slug: 'project-architecture', + group: 'Devflare', + navTitle: 'Project Architecture', + readTime: '9 min read', + eyebrow: 'Project setup', + title: + 'Structure Devflare projects around one authored config, explicit runtime files, and package-local deploy ownership', + summary: + 'This is the practical answer to โ€œwhat does a real Devflare project look like on disk?โ€ โ€” from a small worker package, to a multi-surface app, to a hosted SvelteKit package, to a Bun monorepo with several deployable workers.', + description: + 'Devflare projects stay readable when the package boundary is obvious, the authored files stay separate from generated output, and each runtime surface owns its own file. This page maps the common file types, then shows a few real project shapes from this repository so you can set up your package deliberately instead of accumulating conventions by accident.', + highlights: [ + 'Every deployable package still starts with one authored `devflare.config.ts` file.', + 'Worker surfaces like `fetch`, routes, queue, scheduled, email, Durable Objects, entrypoints, workflows, and transport should each live in explicit files when the package actually owns them.', + 'Hosted Vite or SvelteKit apps add package-local host files like `vite.config.ts` and `svelte.config.js`, but they still keep Devflare config as the Cloudflare-facing source of truth.', + 'Generated files like `env.d.ts`, `.devflare/**`, and `.wrangler/deploy/**` are outputs, not the authored architecture.', + 'In a monorepo, Turbo can orchestrate validation across the workspace, but package-local `devflare` commands still decide what actually builds or deploys.' + ], + facts: [ + { + label: 'Best for', + value: + 'Teams deciding how to lay out a new Devflare package or a multi-package workspace before file structure gets noisy' + }, + { label: 'Primary authored file', value: '`devflare.config.ts` in each deployable package' }, + { label: 'Generated files', value: '`env.d.ts`, `.devflare/**`, and `.wrangler/deploy/**`' }, + { + label: 'Monorepo rule', + value: 'Validate from the root, but deploy from the package that owns the config' + } + ], + sourcePages: [ + 'README.md', + 'package.json', + 'turbo.json', + 'apps/documentation/README.md', + 'apps/documentation/package.json', + 'apps/documentation/devflare.config.ts', + 'apps/documentation/vite.config.ts', + 'apps/documentation/svelte.config.js', + 'apps/testing/README.md', + 'apps/testing/devflare.config.ts', + 'apps/testing/workers/auth-service/devflare.config.ts', + 'cases/case5/devflare.config.ts', + 'cases/case5/math-service/devflare.config.ts', + 'cases/case18/devflare.config.ts' + ], + sections: [ + { + id: 'file-map', + title: 'Start with authored files, and treat generated files as output', + paragraphs: [ + 'The first architecture decision is not โ€œwhich framework?โ€ It is usually โ€œwhich files in this package are actually authored source of truth?โ€ In Devflare, the stable answer is that `devflare.config.ts`, `package.json`, and your runtime files are authored; generated Wrangler-facing files and generated types are downstream outputs.', + 'That split is what keeps the project reviewable. If a file describes package intent or runtime behavior, author it directly. If a file is emitted by Devflare, a framework adapter, or Wrangler preparation, treat it as disposable output and regenerate it when the source changes.' + ], + table: { + headers: ['Path or pattern', 'Own it when', 'What it means'], + rows: [ + [ + '`devflare.config.ts`', + 'Every deployable package', + 'The authored Devflare source of truth for files, bindings, env overlays, previews, and deployment posture.' + ], + [ + '`package.json`', + 'Every package', + 'Package-local scripts, dependencies, and the command loop that should run from that package.' + ], + [ + '`src/fetch.ts`', + 'The package owns request-wide HTTP behavior', + 'The main worker entry for broad middleware or request handling.' + ], + [ + '`src/routes/**`', + 'The package uses file-based HTTP leaves', + 'URL-specific route handlers that sit beside, or replace, one large fetch file.' + ], + [ + '`src/queue.ts`, `src/scheduled.ts`, `src/email.ts`', + 'The package consumes those platform events', + 'Separate event surfaces instead of burying background logic inside fetch code.' + ], + [ + '`src/do/**/*.ts`', + 'The package owns Durable Object classes', + 'Stateful classes discovered and bundled through config.' + ], + [ + '`src/ep/**/*.ts`', + 'The package exposes named worker entrypoints', + 'Classes discovered for typed `ref().worker(...)` service boundaries.' + ], + [ + '`src/workflows/**/*.ts`', + 'The package owns workflow definitions', + 'Additional discovered runtime modules that stay explicit in config review.' + ], + [ + '`src/transport.ts`', + 'Local RPC-style bridge calls must preserve custom values', + 'Custom encode/decode rules for local bridge-backed calls, most often in tests or Durable Object method round-trips.' + ], + [ + '`env.d.ts`', + 'You run `devflare types`', + 'Generated binding and entrypoint types. Do not hand-edit it.' + ], + [ + '`vite.config.ts`, `svelte.config.js`, `src/routes/+page.svelte`', + 'The package is a hosted Vite or SvelteKit app', + 'Host-app files that sit around the Devflare worker story instead of replacing it.' + ], + [ + '`.devflare/**`, `.wrangler/deploy/**`', + 'Devflare has built, checked, or prepared deploy output', + 'Generated build and deploy artifacts. Useful to inspect, not the authored architecture.' + ] + ] + }, + callouts: [ + { + tone: 'success', + title: 'A good architecture rule', + body: [ + 'If the file describes package intent, author it. If the file exists because Devflare or a host tool generated it, inspect it when needed but keep the authored source elsewhere.' + ] + } + ] + }, + { + id: 'starter-package', + title: 'A worker-first package can stay small for a long time', + paragraphs: [ + 'A healthy Devflare package can start with one config file, one `src/fetch.ts`, one route tree, and one small test. That already gives you package-local scripts, generated types, generated deploy output, and room to grow without forcing a framework or a monorepo strategy on day one.', + 'The point of this shape is not minimalism for its own sake. It is that the package boundary stays obvious: the package owns its config, owns its worker files, and can be built or deployed without pretending the whole repo is one worker.' + ], + snippets: [ + { + title: + 'Small worker package with one config, one fetch file, one route tree, and generated output kept in its lane', + activeFile: 'devflare.config.ts', + structure: projectArchitectureStarterStructure, + files: [ + { + path: 'package.json', + language: 'json', + code: projectArchitectureStarterPackageCode + }, + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 10]], + code: projectArchitectureStarterConfigCode + }, + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[3, 8]], + code: projectArchitectureStarterFetchCode + }, + { + path: 'src/routes/health.ts', + language: 'ts', + code: projectArchitectureStarterRouteCode + } + ] + } + ], + bullets: [ + 'Keep the package-local command loop in `package.json` so `types`, `dev`, `build`, and `deploy` always resolve the right config.', + 'Keep `src/fetch.ts` request-wide and let `src/routes/**` own the URL-specific work once there is more than one leaf.', + 'Expect `env.d.ts`, `.devflare/**`, and `.wrangler/deploy/**` to appear as generated outputs after the normal command loop runs.' + ] + }, + { + id: 'multi-surface-package', + title: 'One package can own many runtime files without becoming a monolith', + paragraphs: [ + 'This is where Devflare architecture becomes more interesting than โ€œone fetch file.โ€ A single package can still own HTTP, route modules, queue work, scheduled jobs, email handlers, Durable Objects, named entrypoints, workflows, and transport rules โ€” as long as each surface keeps its own file and the config names those surfaces explicitly.', + 'The `files.*` lane matters for this reason. It is the map of which runtime surfaces the package actually owns.' + ], + snippets: [ + { + title: 'A single package with all the main worker-owned file types visible on disk', + activeFile: 'devflare.config.ts', + structure: projectArchitectureFullSurfaceStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[4, 32]], + code: projectArchitectureFullSurfaceConfigCode + }, + { + path: 'src/queue.ts', + language: 'ts', + code: projectArchitectureFullSurfaceQueueCode + }, + { + path: 'src/do/session-room.ts', + language: 'ts', + code: projectArchitectureFullSurfaceDurableObjectCode + } + ] + } + ], + table: { + headers: ['File lane', 'Why it exists'], + rows: [ + ['`src/fetch.ts`', 'Request-wide middleware and the outer HTTP trail.'], + [ + '`src/routes/**`', + 'Leaf handlers that mirror URLs instead of bloating the global fetch file.' + ], + [ + '`src/queue.ts`, `src/scheduled.ts`, `src/email.ts`', + 'Background and platform-triggered event surfaces with their own runtime contracts.' + ], + [ + '`src/do/**/*.ts`', + 'Stateful Durable Object classes discovered and bundled through config.' + ], + ['`src/ep/**/*.ts`', 'Named worker entrypoints for typed cross-worker boundaries.'], + [ + '`src/workflows/**/*.ts`', + 'Workflow definitions discovered as part of the package runtime shape.' + ], + [ + '`src/transport.ts`', + 'Local bridge serialization only when custom values need to survive a bridge-backed call.' + ] + ] + }, + callouts: [ + { + tone: 'warning', + title: 'Not every package should own every file type', + body: [ + 'The point is explicit ownership, not maximal surface area. Add each runtime file only when the package really owns that event or discovery lane.' + ] + } + ] + }, + { + id: 'hosted-apps', + title: 'Hosted apps add Vite or SvelteKit around the worker, not instead of it', + paragraphs: [ + 'The docs app in this repo is the simplest real example of a hosted package: it has `package.json`, `devflare.config.ts`, `vite.config.ts`, `svelte.config.js`, Svelte route files, and static assets. Devflare still owns the Cloudflare-facing config and generated Wrangler output, while Vite and SvelteKit own the host-app shell.', + 'The repo also includes a fuller SvelteKit case that points `files.fetch` at the generated Cloudflare worker output while still discovering Durable Objects and transport hooks from source. That is the important hosted-app lesson: the framework shell and the worker surfaces can coexist in one package when the file ownership stays explicit.' + ], + snippets: [ + { + title: 'Real hosted app package from `apps/documentation`', + activeFile: 'apps/documentation/devflare.config.ts', + structure: projectArchitectureHostedAppStructure, + files: [ + { + path: 'apps/documentation/package.json', + language: 'json', + code: projectArchitectureHostedAppPackageCode + }, + { + path: 'apps/documentation/devflare.config.ts', + language: 'ts', + focusLines: [[5, 21]], + code: projectArchitectureHostedAppConfigCode + }, + { + path: 'apps/documentation/vite.config.ts', + language: 'ts', + focusLines: [[5, 11]], + code: projectArchitectureHostedAppViteCode + } + ] + }, + { + title: 'Hosted SvelteKit package that still owns extra worker surfaces', + language: 'ts', + code: projectArchitectureSveltekitCase18ConfigCode + } + ], + bullets: [ + 'Package-local host files like `vite.config.ts` and `svelte.config.js` belong beside the Devflare config, not in a separate orchestration package.', + 'Hosted apps can point at generated framework worker output, or they can mix that output with extra Devflare-owned surfaces like Durable Objects and transport hooks.', + 'The generated worker file still belongs on the generated side of the boundary; the authored source remains the config plus the source files that feed it.' + ] + }, + { + id: 'monorepo-example', + title: + 'In a monorepo, Turbo orchestrates the workspace but packages still deploy themselves', + paragraphs: [ + 'This repository is the monorepo example. The root owns workspace scripts, workspaces, and Turbo task orchestration. But deployable packages still keep their own `devflare.config.ts` files and package-local commands. That is true for `apps/documentation`, `apps/testing`, sidecar workers under `apps/testing/workers/*`, and the smaller cases under `cases/*`.', + 'That split is what keeps the monorepo honest. Root scripts decide what to validate or cache. Package-local Devflare commands decide what actually resolves, builds, deploys, or cleans up.' + ], + snippets: [ + { + title: 'The repo root orchestrates, but the packages still own deployment', + activeFile: 'package.json', + structure: projectArchitectureMonorepoStructure, + files: [ + { + path: 'package.json', + language: 'json', + code: projectArchitectureMonorepoRootPackageCode + }, + { + path: 'turbo.json', + language: 'json', + code: projectArchitectureMonorepoTurboCode + }, + { + path: 'apps/testing/workers/auth-service/devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig } from '../../../../packages/devflare/src/config-entry' + +export default defineConfig({ + name: 'devflare-testing-auth-service', + files: { + fetch: 'src/worker.ts' + } +})` + } + ] + }, + { + title: 'Good monorepo command split', + language: 'bash', + code: projectArchitectureMonorepoCommandsCode + } + ], + steps: [ + 'Use the repo root for Turbo build, test, check, and impacted-package orchestration.', + 'Run `devflare` from the package that owns the config you actually mean to resolve.', + 'Keep sidecar workers or service-bound packages as separate workspace packages with their own configs and scripts.', + 'Reuse one preview scope across a worker family only after you have made the package boundaries explicit.' + ], + callouts: [ + { + tone: 'warning', + title: 'Turbo is not the deploy target', + body: [ + 'Turbo decides which packages need work. The package working directory still decides which `devflare.config.ts` gets built or deployed.' + ] + } + ] + }, + { + id: 'next-reads', + title: 'Open the deeper page for the part of the architecture you are deciding next', + cards: [ + { + label: 'Configuration', + title: 'Need the file-surface rules?', + body: 'Open project shape when the next question is how many surfaces the package should actually own and which conventions should stay explicit.', + href: docsLink('project-shape') + }, + { + label: 'Configuration', + title: 'Need the event-surface map?', + body: 'Open worker surfaces when the real question is fetch versus queue versus scheduled versus email, or when the package has started owning more than one event family.', + href: docsLink('worker-surfaces') + }, + { + label: 'Routing', + title: 'Need route layout next?', + body: 'Open the routing page when the package boundary is clear and the next decision is how `src/fetch.ts` and `src/routes/**` should split responsibility.', + href: docsLink('http-routing') + }, + { + label: 'Configuration', + title: 'Need generated types and entrypoints?', + body: 'Open generated types when the architecture includes bindings, named entrypoints, service refs, or Durable Objects that should land in `env.d.ts` accurately.', + href: docsLink('generated-types') + }, + { + label: 'Ship & operate', + title: 'Need the fuller monorepo workflow?', + body: 'Open the monorepo page when the next question is Turbo filters, CI workflow boundaries, or package-local deploy discipline across the workspace.', + href: docsLink('monorepo-turborepo') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/devflare/part-2.ts b/apps/documentation/src/lib/docs/content/devflare/part-2.ts new file mode 100644 index 0000000..fc4de22 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/part-2.ts @@ -0,0 +1,542 @@ +import type { DocPage } from '../../types' +import { + bindingTestingGuideCards, + bindingTestingGuideRows, + docsLink, + projectArchitectureFullSurfaceConfigCode, + projectArchitectureFullSurfaceDurableObjectCode, + projectArchitectureFullSurfaceQueueCode, + projectArchitectureFullSurfaceStructure, + projectArchitectureHostedAppConfigCode, + projectArchitectureHostedAppPackageCode, + projectArchitectureHostedAppStructure, + projectArchitectureHostedAppViteCode, + projectArchitectureMonorepoCommandsCode, + projectArchitectureMonorepoRootPackageCode, + projectArchitectureMonorepoStructure, + projectArchitectureMonorepoTurboCode, + projectArchitectureStarterConfigCode, + projectArchitectureStarterFetchCode, + projectArchitectureStarterPackageCode, + projectArchitectureStarterRouteCode, + projectArchitectureStarterStructure, + projectArchitectureSveltekitCase18ConfigCode, + testingFeelsNativeConfigCode, + testingFeelsNativeDurableObjectCode, + testingFeelsNativeStructure, + testingFeelsNativeTestCode, + testingFeelsNativeTransportCode, + testingFeelsNativeValueCode +} from './shared' + +export const devflareDocsPart2: DocPage[] = [ + { + slug: 'devflare-cli', + group: 'Devflare', + navTitle: 'CLI', + readTime: '9 min read', + eyebrow: 'Command surface', + title: 'Treat `devflare` as one documented CLI, not a bag of one-off shell snippets', + summary: + 'Start at `devflare --help`: the root page already maps local dev, inspection, deploy intent, account inventory, preview lifecycle, production control, token management, AI pricing, and remote-mode operations in one place.', + description: + 'Devflareโ€™s CLI is the public control surface for the same authored config model the docs site describes. Most packages live in the boring `types โ†’ dev โ†’ build โ†’ deploy` loop, but the CLI also owns the surrounding control plane. Learn the root commands once, then drill into `devflare help ` or nested `--help` pages when one family goes deeper.', + highlights: [ + 'The root `devflare --help` page is the fastest map of the whole command surface.', + '`devflare help ` and `devflare --help` resolve to the same detailed guide.', + 'Nested control-plane families such as `account`, `previews`, `productions`, `tokens`, and `remote` have their own subcommand surfaces and their own deeper docs pages.', + 'Keep commands package-local so the resolved `devflare.config.*` is the package you actually mean to act on.' + ], + facts: [ + { + label: 'Best for', + value: + 'Everyday dev, config inspection, explicit deploys, and the Cloudflare control-plane work around those deploys' + }, + { label: 'Fastest orientation', value: '`bunx --bun devflare --help`' }, + { label: 'Help depth', value: '`devflare help [subcommand]`' }, + { + label: 'Safest habit', + value: 'Run commands from the package that owns the `devflare.config.*` you mean to resolve' + } + ], + sourcePages: [ + 'README.md', + 'src/cli/help.ts', + 'src/cli/help-pages/pages/core.ts', + 'src/cli/help-pages/pages/account.ts', + 'src/cli/help-pages/pages/previews.ts', + 'src/cli/help-pages/pages/productions.ts', + 'src/cli/help-pages/pages/misc.ts', + 'src/cli/help-pages/shared.ts' + ], + sections: [ + { + id: 'start-with-help', + title: 'Start with the root help page, then drill down', + paragraphs: [ + 'The root help page is not just a banner and a couple of examples. It is the best quick map of the whole CLI: core dev commands, deploy intent, inspection tools, and the deeper control-plane families all show up there first.', + 'From there, the CLI keeps the same shape all the way down. `devflare help deploy` and `devflare deploy --help` resolve to the same detailed guide, and nested families such as `previews` or `productions` keep going with their own subcommand help instead of forcing you to remember a maze of ad-hoc commands.' + ], + snippets: [ + { + title: 'Use the built-in help tree as the CLI map', + language: 'bash', + code: String.raw`bunx --bun devflare --help +bunx --bun devflare help deploy +bunx --bun devflare previews --help +bunx --bun devflare previews cleanup --help +bunx --bun devflare productions rollback --help` + } + ], + bullets: [ + 'Use the root help first when you are not sure which command family owns the job.', + 'Use command-specific help when the job is already obvious but the option vocabulary is not.', + 'Use nested help for the control-plane families that have real subcommand trees instead of pretending one page can explain them all.' + ], + callouts: [ + { + tone: 'info', + title: 'The docs page should mirror the help tree', + body: [ + 'If the built-in help already describes the command surface cleanly, the docs page should explain that structure instead of flattening everything back into four example commands.' + ] + } + ] + }, + { + id: 'root-command-map', + title: 'Know what each root command family owns', + table: { + headers: ['Command', 'Primary job', 'What the deeper help covers'], + rows: [ + ['`init`', 'Scaffold a new package.', 'Template choice and generated starter scripts.'], + [ + '`dev`', + 'Start local development.', + 'Worker-only defaults, Vite auto-detection, `ref()` service workers, runtime-port selection, logging, and persistence.' + ], + [ + '`build`', + 'Compile deploy-ready artifacts.', + 'Environment resolution and Wrangler-facing output.' + ], + [ + '`deploy`', + 'Ship explicitly to production or preview.', + 'Target selection, dry runs, preview naming, messages, and tags.' + ], + [ + '`types`', + 'Generate `env.d.ts` and typed bindings.', + 'Custom output paths plus entrypoint and Durable Object discovery.' + ], + [ + '`doctor`', + 'Check local project health.', + 'Config, package, TypeScript, Vite, scope-aware local/deploy artifact diagnostics, and optional plugin guidance.' + ], + [ + '`config`', + 'Print resolved config.', + '`print`, raw Devflare JSON, compiled Wrangler JSON, and build/local/deploy resolution phases.' + ], + [ + '`account`', + 'Inspect Cloudflare account inventories and limits.', + 'Resource lists, usage limits, and interactive global/workspace selection.' + ], + [ + '`login`', + 'Authenticate with Cloudflare via Wrangler.', + '`--force` behavior and reuse of existing sessions.' + ], + [ + '`previews`', + 'Operate on preview lifecycle state.', + '`list`, `bindings`, and `cleanup`.' + ], + [ + '`productions`', + 'Inspect and mutate live production state.', + '`versions`, `rollback`, and `delete`.' + ], + [ + '`worker`', + 'Run Worker control-plane operations.', + 'Currently `rename`, plus config-sync expectations.' + ], + [ + '`tokens`', + 'Manage Devflare-managed account-owned API tokens.', + 'List, create, roll, and delete managed tokens.' + ], + [ + '`ai`', + 'Print the bundled Workers AI pricing snapshot.', + 'Read-only pricing surface; verify current rates in Cloudflare docs when it matters.' + ], + [ + '`remote`', + 'Toggle remote test mode for paid features.', + '`status`, `enable`, and `disable`.' + ], + [ + '`help`', + 'Render root or command-specific help.', + 'Nested help resolution for command families and subcommands.' + ], + [ + '`version`', + 'Print the installed version.', + 'Same information as the global `--version` flag.' + ] + ] + } + }, + { + id: 'common-options', + title: 'Learn the shared option vocabulary once', + paragraphs: [ + 'The root help page also teaches the common option vocabulary. That matters because not every command supports every option, but the meaning stays consistent when the option exists.', + 'If you already know what `--config`, `--env`, `--debug`, and `--help` mean, the command-specific help pages get much easier to scan.' + ], + table: { + headers: ['Option', 'What it means', 'Where it matters most'], + rows: [ + [ + '`--config `', + 'Pick the exact `devflare.config.*` file to resolve.', + '`build`, `deploy`, `types`, `doctor`, `config`, `previews`, `productions`, and `worker rename`.' + ], + [ + '`--env `', + 'Resolve `config.env[name]` before the command runs.', + '`build`, `config`, preview-aware inspection, and production discovery flows.' + ], + [ + '`--debug`', + 'Print stack traces and extra debug output.', + 'Build, deploy, type generation, and other failure-heavy paths.' + ], + [ + '`--no-color`', + 'Disable ANSI color output.', + 'CI logs, copied transcripts, or plain-text debugging.' + ], + [ + '`-h, --help`', + 'Show the detailed help page for the current command path.', + 'Every root command and nested subcommand surface.' + ], + [ + '`-v, --version`', + 'Print the installed version and exit.', + 'Root invocation when you need to verify the installed package quickly.' + ] + ] + }, + bullets: [ + '`--env` is meaningful only on commands that actually resolve config environments.', + '`--help` is not a fallback after confusion; it is the intended first stop for a new command family.', + 'When in doubt about which config file is being resolved, make `--config` explicit instead of trusting directory luck.' + ] + }, + { + id: 'nested-control-plane', + title: 'Use the root page as the map, then let deeper pages own the sharp edges', + paragraphs: [ + 'The root CLI page should tell you which family exists and what it is broadly for. Once a command starts operating on preview lifecycle, live production, account context, tokens, or paid-test gates, the sharper behavior belongs on the dedicated operations pages instead of being re-explained here in parallel.', + 'Use the built-in help for exact flags, then use the docs pages below for the operational safety rules and workflow context around those command families.' + ], + cards: [ + { + href: docsLink('control-plane-operations'), + label: 'Ship & operate', + meta: 'Operations', + title: 'Control-plane operations', + body: 'Open this page for account selection, live production inspection, rollback or delete posture, worker rename, token bootstrap, and remote-mode gates.' + }, + { + href: docsLink('cloudflare-api'), + label: 'Ship & operate', + meta: 'Library API', + title: 'devflare/cloudflare', + body: 'Open this page when a script or tool should use the same account, registry, usage, and token helpers the CLI builds on.' + }, + { + href: docsLink('preview-operations'), + label: 'Ship & operate', + meta: 'Preview lifecycle', + title: 'Preview operations', + body: 'Open this page when the question is preview registry inspection or resource cleanup.' + }, + { + href: docsLink('production-deploys'), + label: 'Ship & operate', + meta: 'Deploy targets', + title: 'Production deploys', + body: 'Open this page when the question is the deploy target and preflight inspection rather than later control-plane changes.' + } + ], + bullets: [ + 'Use `account`, `productions`, `worker`, `tokens`, and `remote` when you are operating real Cloudflare state instead of just building locally.', + 'Use `previews` when the job is preview lifecycle rather than day-to-day package development.', + 'Treat nested `--apply` flows as command families that deserve both built-in help and the dedicated docs page before you run them.' + ], + callouts: [ + { + tone: 'warning', + title: 'The sharp edges live one level deeper', + body: [ + '`previews cleanup`, `productions rollback`, and `productions delete` all carry behavior and safety notes that are too specific for the root CLI map. Read their help and the dedicated docs page before treating them as copy-paste habits.' + ] + } + ] + }, + { + id: 'daily-loop', + title: 'Most packages still live in one boring, reliable command loop', + paragraphs: [ + 'The most useful Devflare loop is intentionally repetitive: refresh generated types when bindings move, run local dev, inspect build output when the shape changes, and deploy with an explicit preview or production target.', + 'That loop stays the same whether the package is worker-only or Vite-backed. The config decides the host; the command vocabulary stays familiar.', + 'When the job changes from building to operating, switch command families instead of inventing ad-hoc command snippets: `config` and `doctor` for inspection, `previews` for preview lifecycle, `productions` for live production state, and `account` for inventory questions.' + ], + snippets: [ + { + title: 'Map the everyday CLI loop into package scripts', + description: + 'Keep scripts thin and explicit so local developers and CI both call the same Devflare command surface.', + filename: 'package.json', + language: 'json', + code: String.raw`{ + "scripts": { + "dev": "devflare dev", + "types": "devflare types", + "build": "devflare build --env staging", + "deploy:preview": "devflare deploy --preview next", + "deploy:prod": "devflare deploy --prod", + "doctor": "devflare doctor" + } +}` + }, + { + title: 'A good everyday command loop', + language: 'bash', + code: String.raw`bunx --bun devflare types +bunx --bun devflare dev +bunx --bun devflare build --env staging +bunx --bun devflare deploy --preview next +bunx --bun devflare deploy --prod` + }, + { + title: 'When the setup feels suspicious, inspect before you improvise', + language: 'bash', + code: String.raw`bunx --bun devflare config print --format wrangler +bunx --bun devflare config --phase local --format wrangler +bunx --bun devflare doctor --scope local +bunx --bun devflare previews bindings --scope next +bunx --bun devflare productions versions` + } + ], + bullets: [ + 'Run `types` after binding or entrypoint changes so `env.d.ts` stays honest.', + 'Use `dev --runtime-port ` or `DEVFLARE_RUNTIME_PORT` when another local project already owns the default 8787 runtime port.', + 'Use `config --phase local --format wrangler` when you want local config inspection without Cloudflare account lookups.', + 'Use `ref()` service bindings for local full-stack packages; Devflare starts those referenced workers in CLI dev and exposes them as Vite auxiliary workers for framework dev.', + 'Run `build` or `config print --format wrangler` when the compiled shape matters more than the dev server feeling healthy.', + 'Use `doctor --scope local` when generated deploy artifacts are intentionally absent during a local-only loop.', + 'Keep preview and production intent explicit in the final deploy command instead of hiding it in a generic script name.', + 'Use the nested help pages when a lifecycle command reaches `--apply`, account selection, rollback, or cleanup territory.' + ] + }, + { + id: 'inspection-recovery', + title: 'Use the inspection and lifecycle commands before you improvise command snippets', + cards: [ + { + title: '`config print`', + body: 'Best when you need to see the resolved Devflare config or compiled Wrangler-facing shape before trusting a build or deploy.' + }, + { + title: '`doctor`', + body: 'Best when config resolution, generated artifacts, or local Vite detection feel hard to trace and need a sharper diagnostic pass.' + }, + { + title: '`previews` / `productions`', + body: 'Best when the question is no longer โ€œcan I deploy?โ€ but โ€œwhat exists right now, and what should I clean up, roll back, or inspect?โ€' + } + ], + callouts: [ + { + tone: 'warning', + title: 'Keep commands package-local', + body: [ + 'Run Devflare from the package that owns the config you actually mean to resolve. In monorepos, Turbo can decide what changed, but package-local `devflare` commands still decide what gets built, deployed, inspected, or cleaned up.' + ] + } + ] + } + ] + }, + { + slug: 'sequence-middleware', + group: 'Devflare', + navTitle: 'sequence(...)', + readTime: '5 min read', + eyebrow: 'Runtime helper', + title: + 'Compose request-wide middleware with `sequence(...)` instead of burying flow control inside one big fetch file', + summary: + 'Use `sequence(...)` from `devflare/runtime` when broad HTTP concerns must wrap route resolution or another fetch handler in a clear top-to-bottom order.', + description: + '`sequence(...)` composes `(event, resolve)` middleware for workers so broad concerns stay readable without burying them in one monolithic fetch file.', + highlights: [ + 'Import `sequence` from `devflare/runtime` for worker fetch middleware.', + 'Keep global concerns like CORS, auth, request ids, and response shaping in the sequence chain, not in route leaves.', + '`resolve(event)` continues into the next middleware or the matched route handler, and it can receive a replacement `FetchEvent` when middleware intentionally forwards a modified request.', + 'Export exactly one primary fetch entry per module: `fetch` or `handle`, not both.' + ], + facts: [ + { + label: 'Best for', + value: 'Request-wide concerns that should wrap routes or another fetch handler cleanly' + }, + { label: 'Primary signature', value: '`(event, resolve) => Response`' }, + { label: 'Good pairing', value: '`src/fetch.ts` plus `src/routes/**` leaf handlers' } + ], + sourcePages: [ + 'packages/devflare/README.md', + 'packages/devflare/src/dev-server/server.ts', + 'README.md', + 'src/runtime/middleware.ts' + ], + sections: [ + { + id: 'main-shape', + title: 'Use `sequence(...)` for the broad concerns that should wrap the whole HTTP flow', + paragraphs: [ + 'The cleanest use of `sequence(...)` is broad request-wide behavior: CORS, auth guards, request ids, logging, response shaping, or any other concern that should wrap route resolution instead of being reimplemented in each leaf handler.', + 'That keeps `src/fetch.ts` focused on the global HTTP contract while route files stay small and URL-specific.' + ], + snippets: [ + { + title: 'A small global middleware chain', + activeFile: 'src/fetch.ts', + structure: [ + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/users/[id].ts' } + ], + files: [ + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function cors(event: FetchEvent, resolve: ResolveFetch): Promise { + if (event.request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + }) + } + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('Access-Control-Allow-Origin', '*') + return next +} + +export const handle = sequence(cors)` + }, + { + path: 'src/routes/users/[id].ts', + language: 'ts', + code: String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ params }: FetchEvent): Promise { + return Response.json({ id: params.id }) +}` + } + ] + } + ] + }, + { + id: 'what-belongs-in-chain', + title: 'Use the chain for broad concerns, not leaf business logic', + cards: [ + { + title: 'Good fit', + body: 'CORS, auth checks, request ids, logging, response headers, or other concerns that should apply before or after the final leaf handler.' + }, + { + title: 'Usually the wrong fit', + body: 'Business logic that only matters for one URL. If it is leaf-specific, keep it in the matched route file instead of global middleware.' + } + ], + callouts: [ + { + tone: 'accent', + title: 'The split should stay boring', + body: [ + 'Global middleware should read like app policy. Route files should read like one URL at a time. If those blur together, the HTTP layer gets harder to review than it needs to be.' + ] + } + ] + }, + { + id: 'method-handlers', + title: 'Route files can export per-method handlers', + paragraphs: [ + 'Route modules can export named functions for specific HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `ALL`. The runtime resolves the matching export based on the request method.', + '`HEAD` requests fall back to `GET` when no `HEAD` export exists, and the response body is stripped automatically. `ALL` is the catch-all when no method-specific export matches.' + ], + table: { + headers: ['Export', 'Matches', 'Fallback behavior'], + rows: [ + ['`GET`', '`GET` requests', 'โ€”'], + ['`POST`', '`POST` requests', 'โ€”'], + ['`PUT`', '`PUT` requests', 'โ€”'], + ['`PATCH`', '`PATCH` requests', 'โ€”'], + ['`DELETE`', '`DELETE` requests', 'โ€”'], + ['`HEAD`', '`HEAD` requests', 'Falls back to `GET` with body stripped'], + ['`ALL`', 'Any method not matched by a specific export', 'โ€”'] + ] + }, + bullets: [ + 'A handler with two parameters receives `(event, params)` as a convenience shorthand.', + 'A handler with an `(event, resolve)` signature is called in resolve-style, consistent with `sequence(...)` middleware.', + 'Method handlers resolve after the `sequence(...)` middleware chain.', + '`default` exports are also supported: `export default { GET, POST }` or `export default function handle(event) { ... }`.' + ] + }, + { + id: 'resolve-contract', + title: 'Understand what `resolve(event)` actually means', + paragraphs: [ + 'Calling `resolve(event)` continues into the next middleware in the chain, or into the matched route/module-level handler once no more middleware remains. That makes the order of the chain explicit instead of hidden inside nested helper calls.', + '`resolve(event)` may also receive a replacement `FetchEvent`. That is the supported way for middleware to forward a modified request, preserved params, or updated locals into the next stage deliberately.' + ], + bullets: [ + '`fetch` and `handle` are aliases for the primary fetch entry, so export one or the other, not both.', + 'Same-module method handlers and route resolution happen after the sequence chain passes control onward.', + 'If you are composing SvelteKit hooks, that uses SvelteKitโ€™s own `sequence` helper; it is a separate abstraction from `devflare/runtime` middleware composition.' + ], + callouts: [ + { + tone: 'warning', + title: 'One primary fetch entry per module', + body: [ + 'Devflare rejects ambiguous primary fetch modules. Export either `fetch` or `handle` (or one default equivalent), not several competing entrypoints.' + ] + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/devflare/part-3.ts b/apps/documentation/src/lib/docs/content/devflare/part-3.ts new file mode 100644 index 0000000..4a3ead7 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/part-3.ts @@ -0,0 +1,667 @@ +import type { DocPage } from '../../types' +import { + bindingTestingGuideCards, + bindingTestingGuideRows, + docsLink, + projectArchitectureFullSurfaceConfigCode, + projectArchitectureFullSurfaceDurableObjectCode, + projectArchitectureFullSurfaceQueueCode, + projectArchitectureFullSurfaceStructure, + projectArchitectureHostedAppConfigCode, + projectArchitectureHostedAppPackageCode, + projectArchitectureHostedAppStructure, + projectArchitectureHostedAppViteCode, + projectArchitectureMonorepoCommandsCode, + projectArchitectureMonorepoRootPackageCode, + projectArchitectureMonorepoStructure, + projectArchitectureMonorepoTurboCode, + projectArchitectureStarterConfigCode, + projectArchitectureStarterFetchCode, + projectArchitectureStarterPackageCode, + projectArchitectureStarterRouteCode, + projectArchitectureStarterStructure, + projectArchitectureSveltekitCase18ConfigCode, + testingFeelsNativeConfigCode, + testingFeelsNativeDurableObjectCode, + testingFeelsNativeStructure, + testingFeelsNativeTestCode, + testingFeelsNativeTransportCode, + testingFeelsNativeValueCode +} from './shared' + +export const devflareDocsPart3: DocPage[] = [ + { + slug: 'why-testing-feels-native', + group: 'Devflare', + navTitle: 'Why tests feel native', + readTime: '7 min read', + eyebrow: 'Testing advantage', + title: 'Why Devflare tests feel like using the worker instead of mocking around it', + summary: + 'Devflareโ€™s standout testing trick is that the same config, bindings, env surface, runtime helpers, and even direct Durable Object method calls can stay available in Bun tests without a hand-built fake layer in the middle.', + description: + 'The experience feels better because Devflare does more than boot Miniflare. `createTestContext()` loads the nearest config, wires the real worker surfaces, installs runtime-shaped helper entrypoints, and bridges Node or Bun test code back into the worker world so `env`, `cf.*`, and bridge-backed Durable Object calls keep the same mental model.', + highlights: [ + 'The same authored config drives the app and the tests; there is no separate test-only binding schema to babysit.', + 'The unified `env` proxy works inside request handlers, inside `createTestContext()` tests, and through the bridge when code needs to cross back into the worker world.', + '`cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, and `cf.tail` run user code with the same runtime context helpers the app expects.', + 'Durable Object methods can be called directly through `env.MY_DO.getByName(...).myMethod()` instead of forcing every stateful test through HTTP glue.', + 'When a bridge-backed call returns a custom class, `src/transport.ts` can rebuild that class on the caller side instead of flattening it into plain JSON.' + ], + facts: [ + { label: 'Key advantage', value: 'Tests can stay worker-shaped instead of mock-shaped' }, + { + label: 'Core trick', + value: '`createTestContext()` plus a unified `env` proxy and bridge-backed bindings' + }, + { + label: 'Durable Object experience', + value: 'Direct `env.COUNTER.getByName(...).increment()` calls in tests' + }, + { + label: 'Optional extra', + value: '`src/transport.ts` when bridge-backed calls must round-trip custom classes' + } + ], + sourcePages: [ + 'src/test/simple-context.ts', + 'src/test/simple-context-durable-objects.ts', + 'src/test/simple-context-gateway-script.ts', + 'src/test/cf.ts', + 'src/test/worker.ts', + 'src/test/queue.ts', + 'src/test/resolve-service-bindings.ts', + 'src/bridge/proxy.ts', + 'src/bridge/client.ts', + 'src/env.ts', + 'tests/integration/test-context/config-autodiscovery.test.ts' + ], + sections: [ + { + id: 'why-it-feels-better', + title: 'The experience feels better because Devflare removes a whole fake layer', + paragraphs: [ + 'A lot of Worker testing feels disconnected. One layer of code is written against real bindings and Worker surfaces, then the tests either fake those APIs by hand or retreat to heavier integration paths for everything.', + 'Devflare tries to keep one authored story instead. The same config that boots the app can boot the test harness, the same `env` import can keep working, and bridge-backed bindings can cross from Bun back into the worker world without forcing every test to speak raw HTTP or a custom mock vocabulary.' + ], + cards: [ + { + title: 'One config', + body: '`createTestContext()` loads the same `devflare.config.*` model the app uses instead of a second test-only binding map.' + }, + { + title: 'One env surface', + body: 'The unified `env` proxy uses request context in handlers, test context in tests, and the bridge when code needs to reach Miniflare-backed bindings.' + }, + { + title: 'One set of helper surfaces', + body: '`cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, and `cf.tail` trigger the same handler families your package actually owns.' + }, + { + title: 'One honest Durable Object story', + body: 'Direct `env.MY_DO.getByName(...).method()` calls work in tests, so stateful code does not need a fake facade just to become testable.' + } + ], + callouts: [ + { + tone: 'accent', + title: 'This is the key advantage', + body: [ + 'Devflare is at its best when a test can read like app code instead of a ceremony for building a fake Cloudflare universe first.' + ] + } + ] + }, + { + id: 'bridge-layers', + title: 'The bridge is the difference, but it is not the only layer doing useful work', + paragraphs: [ + 'The seamless part comes from several user-visible pieces cooperating: config autodiscovery, the unified `env` proxy, runtime-shaped helper entrypoints, and bridge proxies that forward binding calls into the local worker world.', + 'That is also why Devflare testing scales beyond one fetch route. The same system can cover direct binding calls, queue and scheduled helpers, Tail events, and bridge-backed Durable Object or service interactions without making you rewire the whole harness every time the package grows a new surface.' + ], + table: { + headers: ['Layer', 'What Devflare wires', 'Why it feels smoother'], + rows: [ + [ + '`createTestContext()`', + 'Finds the nearest config, boots Miniflare, discovers worker surfaces, and prepares bindings from the same authored project shape.', + 'The harness starts where the app starts instead of from a separate test-only setup story.' + ], + [ + 'Unified `env` proxy', + 'Prefers request-scoped env, then test-context env, then bridge-backed env access.', + "One `import { env } from 'devflare'` can stay valid across app code, tests, and local bridge-backed flows." + ], + [ + '`cf.*` helpers', + 'Create runtime-shaped fetch, queue, scheduled, email, and tail events/controllers before user code runs.', + 'Helpers such as `getFetchEvent()` and `locals` keep working in tests instead of only in real requests.' + ], + [ + 'Bridge proxies', + 'Route KV, D1, R2, Durable Object, queue, service, and send-email calls into the local worker world.', + 'Bindings can be exercised through their real shapes instead of custom in-memory fakes.' + ], + [ + 'Transport hooks', + 'Optionally encode and decode custom values for local RPC-style bridge calls.', + 'A Durable Object method can return a real class again on the caller side when that behavior matters.' + ] + ] + }, + bullets: [ + 'Service binding refs and cross-worker Durable Object refs can trigger extra worker resolution automatically, so multi-worker tests still begin from the same config model.', + 'For single-worker tests, the bridge-backed env proxy is the normal path. For multi-worker refs, `createTestContext()` can boot the extra workers directly through Miniflare worker configuration.', + 'The bridge is there to remove translation pain, not to make the test vocabulary magical or mysterious.' + ] + }, + { + id: 'durable-object-round-trip', + title: + 'This is the part that usually sells people: a Durable Object method can feel native in a test', + paragraphs: [ + "One of Devflare's nicest testing moves is that a Durable Object method can be called straight from the test through `env.COUNTER.getByName('main').increment(2)` instead of forcing you through a fake stub or an HTTP wrapper route.", + 'When the return value is more than plain JSON, `src/transport.ts` can keep the bridge honest by rebuilding the real class on the caller side. That is how a local test can still receive a `DoubleableNumber` with working instance behavior instead of a flattened object.' + ], + snippets: [ + { + title: 'The test reads like app code, not like bridge setup', + description: + 'This mirrors the integration behavior Devflare proves itself: config autodiscovery, a direct Durable Object method call, and a custom class round-trip through `transport.ts`.', + activeFile: 'tests/counter.test.ts', + structure: testingFeelsNativeStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 11]], + code: testingFeelsNativeConfigCode + }, + { + path: 'src/DoubleableNumber.ts', + language: 'ts', + focusLines: [[1, 10]], + code: testingFeelsNativeValueCode + }, + { + path: 'src/transport.ts', + language: 'ts', + focusLines: [[1, 8]], + code: testingFeelsNativeTransportCode + }, + { + path: 'src/do.counter.ts', + language: 'ts', + focusLines: [[1, 10]], + code: testingFeelsNativeDurableObjectCode + }, + { + path: 'tests/counter.test.ts', + language: 'ts', + focusLines: [[1, 13]], + code: testingFeelsNativeTestCode + } + ] + } + ], + callouts: [ + { + tone: 'success', + title: 'The bridge disappears when it is working well', + body: [ + 'That is the real win. You still benefit from the bridge, but the test itself mostly reads like โ€œboot the worker, call the thing, assert the domain value.โ€' + ] + } + ] + }, + { + id: 'not-just-http', + title: 'The same smooth story extends beyond plain HTTP', + table: { + headers: ['Surface', 'What the test calls', 'What Devflare keeps aligned'], + rows: [ + [ + 'Routes and fetch middleware', + '`cf.worker.get()` or `cf.worker.fetch()`', + 'Request shape, route params, and runtime helper access.' + ], + [ + 'Queue consumers', + '`cf.queue.trigger()`', + 'Batch shape, retry or ack behavior, and queued `waitUntil()` work.' + ], + [ + 'Scheduled jobs', + '`cf.scheduled.trigger()`', + 'Cron controller shape, scheduled context, and background work timing.' + ], + [ + 'Email and tail handlers', + '`cf.email.send()` and `cf.tail.trigger()`', + 'Handler-style invocation with the right local helper semantics instead of custom throwaway scaffolding.' + ], + [ + 'Bindings and Durable Object methods', + '`env.DB`, `env.CACHE`, `env.FILES`, or `env.COUNTER.getByName(...).increment()`', + 'The same binding contract app code uses, optionally with transport-backed custom value round-trips.' + ] + ] + }, + paragraphs: [ + 'That range is why the testing story feels bigger than one fetch helper. Devflare is not only helping you send requests; it is helping your tests talk to the same worker-owned surfaces your app logic actually depends on.', + 'When the package grows queues, schedules, email handlers, or Tail processing, the harness grows with the same worker-shaped mindset instead of forcing a whole new testing abstraction for each runtime surface.' + ], + cards: [ + { + href: docsLink('create-test-context'), + label: 'Testing', + meta: 'Harness details', + title: 'createTestContext()', + body: 'Open this when the next question is the exact helper behavior, autodiscovery rules, or background-work timing.' + }, + { + href: docsLink('transport-file'), + label: 'Runtime', + meta: 'Bridge transport', + title: 'transport.ts', + body: 'Open this when the next question is how to preserve real class instances across a local bridge-backed RPC call.' + }, + { + href: docsLink('binding-testing-guides'), + label: 'Testing', + meta: 'Binding-specific', + title: 'Binding testing guides', + body: 'Jump here when the binding is already chosen and the only remaining question is the most honest test posture for that binding.' + } + ] + }, + { + id: 'keep-it-honest', + title: 'Caveats worth knowing', + bullets: [ + '`cf.worker.fetch()` returns when the handler resolves, so some `waitUntil()` side effects may still be running afterward.', + '`transport.ts` is for bridge-backed RPC-style calls, not a replacement for normal HTTP request or response serialization.', + 'Remote-heavy bindings such as AI and Vectorize still need higher-fidelity or remote checks sooner than KV, D1, R2, or many Durable Object flows do.', + 'Preview and CI validation still matter for Cloudflare ingress, routing, and deployment lifecycle questions that local tests do not pretend to answer completely.' + ], + callouts: [ + { + tone: 'warning', + title: 'Smooth local tests are the default, not the whole verification plan', + body: [ + 'Devflare makes honest local tests much easier, but it does not claim that every Cloudflare behavior is now a unit test. The strong story is โ€œless mocking, more truthful local coverage, then higher-fidelity checks when the question changes.โ€' + ] + } + ] + } + ] + }, + { + slug: 'testing-overview', + group: 'Devflare', + navTitle: 'Testing overview', + readTime: '7 min read', + eyebrow: 'Testing map', + title: 'Use one testing map so you know which Devflare page answers which testing question', + summary: + 'Devflareโ€™s testing story is layered: start with one real unit test, use `createTestContext()` and `cf.*` for the runtime-shaped harness, then jump to binding-specific guides or CI-focused pages only when the question changes.', + description: + 'The docs already explain starter tests, harness behavior, runtime-context caveats, transport round-trips, binding-specific testing, and automation. This page gathers those lanes into one map so you can open the right testing page first instead of re-deriving the docs structure from memory.', + highlights: [ + 'Start with `your first unit test` when the goal is simply โ€œprove the worker boots and answers one request.โ€', + 'Open `Why tests feel native` when the question is what makes Devflareโ€™s bridge-backed harness feel smoother than the usual Worker testing setup.', + 'Use `createTestContext()` when you need the real worker surface, helper timing rules, and autodiscovery behavior.', + 'Every binding overview page already links its own testing guide at the bottom in the โ€œGo deeperโ€ section.', + 'Use `Testing & automation` when the question shifts from local harness behavior to CI, preview validation, and workflow observability.' + ], + facts: [ + { + label: 'Best for', + value: 'Finding the right testing doc before you disappear into the wrong rabbit hole' + }, + { label: 'Default harness', value: '`createTestContext()` plus `cf.*` helpers' }, + { + label: 'Binding-specific docs', + value: 'At the bottom of each binding overview page and in the binding testing index' + }, + { + label: 'Automation lane', + value: '`/docs/testing-and-automation` for CI, preview checks, and workflow feedback' + } + ], + sourcePages: [ + 'packages/devflare/src/test/simple-context.ts', + 'README.md', + 'simple-context.ts', + 'cf.ts', + 'apps/testing/*' + ], + sections: [ + { + id: 'start-with-one-proof', + title: 'Start with one honest proof before you optimize the testing story', + paragraphs: [ + 'The safest Devflare testing habit is boring: prove one worker path with one real request first, then only add more harness machinery when a binding, background surface, or preview concern genuinely needs it.', + 'The docs split testing into layers for this reason. A starter request test, a runtime-shaped harness page, binding-specific testing guides, and a CI/automation page each answer different questions. Trying to make one page carry all of that usually makes the guidance worse.' + ], + snippets: [ + { + title: 'The boring first loop is still the right default', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('GET /health proves the worker boots', async () => { + const response = await cf.worker.get('/health') + expect(response.status).toBe(200) +})` + } + ], + bullets: [ + 'If the worker cannot answer one truthful request, the next testing abstraction is probably not the rescue mission you need.', + 'Start route-level when the app behavior is the point, and binding-level when the binding itself is the point.', + 'Keep one small proof test around even after the suite grows so the runtime contract stays visible.' + ] + }, + { + id: 'open-the-right-page', + title: 'Open the page that matches the question you actually have', + cards: [ + { + href: docsLink('why-testing-feels-native'), + label: 'Testing', + meta: 'Why it feels better', + title: 'Why tests feel native', + body: 'Open this when the question is less โ€œhow do I use the harness?โ€ and more โ€œwhy does Devflare testing feel so much smoother than the usual Worker setup?โ€' + }, + { + href: docsLink('first-unit-test'), + label: 'Quickstart', + meta: 'Starter proof', + title: 'Your first unit test', + body: 'Use this when the goal is simply to prove a worker boots, answers one request, and can be exercised through the real Devflare test harness.' + }, + { + href: docsLink('create-test-context'), + label: 'Testing', + meta: 'Harness', + title: 'createTestContext()', + body: 'Use this when you need the real worker-shaped harness, autodiscovered surfaces, helper timing rules, and the `cf.*` testing helpers.' + }, + { + href: docsLink('binding-testing-guides'), + label: 'Testing', + meta: 'Binding index', + title: 'Binding testing guides', + body: 'Use this when the binding already exists and the open question is how to test KV, D1, R2, Queues, Durable Objects, AI, Vectorize, or another binding accurately.' + }, + { + href: docsLink('runtime-context'), + label: 'Runtime', + meta: 'Runtime helpers', + title: 'Runtime context', + body: 'Open this when missing-context errors, getters, or runtime proxies are making tests feel harder to trace than they should.' + }, + { + href: docsLink('transport-file'), + label: 'Runtime', + meta: 'Bridge transport', + title: 'transport.ts', + body: 'Open this when a test needs a bridge-backed RPC call to return a real class instance instead of collapsing into plain JSON.' + }, + { + href: docsLink('testing-and-automation'), + label: 'Ship & operate', + meta: 'CI and release lanes', + title: 'Testing & automation', + body: 'Use this page when the question changes from local test harness behavior to CI workflows, preview checks, and observable automation.' + } + ] + }, + { + id: 'choose-the-layer', + title: 'The right testing layer depends on what changed', + table: { + headers: ['If the question is...', 'Open this page first', 'Why'], + rows: [ + [ + 'Can I prove the worker answers one real request?', + '`Your first unit test`', + 'It keeps the first check small and prevents the harness from becoming accidental ceremony.' + ], + [ + 'Why does Devflare testing feel smoother than the usual Worker setup?', + '`Why tests feel native`', + 'It explains the unified env, bridge-backed bindings, runtime helper surfaces, and direct Durable Object story.' + ], + [ + 'How does the default runtime-shaped harness behave?', + '`createTestContext()`', + 'It documents autodiscovery, `cf.*`, helper timing, and when the harness waits for background work.' + ], + [ + 'How should I test this specific binding?', + '`Binding testing guides`', + 'Each binding has its own testing page with the right default harness and escalation path.' + ], + [ + 'Why are getters or proxies failing in a test?', + '`Runtime context`', + 'The runtime-context page explains when helper APIs can read the active request, env, ctx, event, and locals.' + ], + [ + 'Why is a custom class not round-tripping in a test?', + '`transport.ts`', + 'Transport docs explain the extra serialization hook for bridge-backed calls.' + ], + [ + 'How should this fit into CI or preview validation?', + '`Testing & automation`', + 'Automation guidance belongs on the CI-facing page, not in the local harness docs.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'One page per question is a feature', + body: [ + 'Devflareโ€™s testing docs are intentionally split so starter tests, binding nuance, runtime context, and automation do not blur into one giant advice blob.' + ] + } + ] + }, + { + id: 'where-binding-guides-live', + title: 'Binding-specific testing pages already exist โ€” they were just easy to miss', + paragraphs: [ + 'Each binding overview page already links its testing and example pages. That means the binding-specific testing content is already in the library, but it was discoverable mostly if you were already reading the right binding page.', + 'Use the binding testing index when you know which binding changed and want the testing guide directly. Use the binding overview page first when you still need the config shape, runtime usage, or local support notes before the tests make sense.' + ], + cards: [ + { + href: docsLink('binding-testing-guides'), + label: 'Testing', + meta: 'Binding index', + title: 'Binding testing guides', + body: 'Jump straight to the testing page for KV, D1, R2, Durable Objects, Queues, AI, Vectorize, Hyperdrive, Browser Rendering, Analytics Engine, or Send Email.' + } + ], + bullets: [ + 'Open the binding overview page when you need config or runtime context first.', + 'Open the binding testing page when the binding already exists and the question is purely about the right harness or escalation path.', + 'Remote-oriented bindings like AI and Vectorize deliberately have a different testing posture from KV or D1, and the testing guides say that out loud.' + ] + } + ] + }, + { + slug: 'binding-testing-guides', + group: 'Devflare', + navTitle: 'Binding testing', + readTime: '8 min read', + eyebrow: 'Testing index', + title: + 'Open the right binding testing guide instead of reconstructing the test story from scratch', + summary: + 'Every binding overview page already links a hidden testing guide. This page collects those guides in one place so you can jump straight to the right harness, caveats, and escalation path for the binding that changed.', + description: + 'Binding testing is not one-size-fits-all. KV, D1, R2, Durable Objects, Queues, and several other bindings are strong local-first stories, while AI, Vectorize, and a few infrastructure-heavy bindings need more remote or higher-fidelity checks sooner. Use this page when you know the binding but do not want to hunt through the whole binding library first.', + highlights: [ + 'Every binding overview page links its testing guide.', + 'Most bindings still start with `createTestContext()` plus the real binding or helper surface, not a hand-built fake.', + 'Remote-oriented guides say so explicitly instead of pretending every binding has the same local story.', + 'Open the binding overview page first when you need config or runtime shape; open the testing guide first when the binding already exists and the only question left is test design.' + ], + facts: [ + { label: 'Best for', value: 'Jumping straight to the right binding-specific testing guide' }, + { + label: 'Where the links also live', + value: 'At the bottom of each binding overview page' + }, + { + label: 'Default pattern', + value: 'Usually `createTestContext()` plus the real binding or helper surface' + }, + { + label: 'Notable exceptions', + value: + 'AI and Vectorize are remote-oriented, and some other bindings need higher-fidelity checks sooner' + } + ], + sourcePages: [ + 'packages/devflare/src/test/simple-context.ts', + 'README.md', + 'simple-context.ts', + 'cf.ts', + 'apps/testing/*' + ], + sections: [ + { + id: 'how-to-use-this-index', + title: 'Use this page as the index, but remember where the links already live', + paragraphs: [ + 'The binding library intentionally keeps only the main binding overview pages visible in the sidebar. The testing pages are still real docs pages, but they stay linked from the bottom of each binding overview so the sidebar does not turn into a twelve-level nesting doll.', + 'That is great once you already opened the right binding page. This index is for the opposite moment: you know the binding that changed and you want the testing guide immediately.' + ], + bullets: [ + 'Open the binding overview page first when you need authoring, runtime, or preview context before the tests make sense.', + 'Open the testing guide first when the binding already exists and the only remaining question is how to test it.', + 'Use `Testing overview` when you need the bigger map across starter tests, harness behavior, binding guides, runtime helpers, and automation.' + ], + cards: [ + { + href: docsLink('testing-overview'), + label: 'Testing', + meta: 'Map', + title: 'Testing overview', + body: 'Use the broader testing map when you are not yet sure whether the next question belongs to starter tests, binding guides, runtime context, or automation.' + } + ] + }, + { + id: 'open-the-guide', + title: 'Open the testing guide for the binding that actually changed', + cards: bindingTestingGuideCards + }, + { + id: 'testing-posture', + title: 'The testing posture is not identical for every binding', + table: { + headers: ['Binding', 'Testing posture', 'Default harness'], + rows: bindingTestingGuideRows + }, + callouts: [ + { + tone: 'warning', + title: 'Different defaults are a good thing', + body: [ + 'KV, D1, R2, and Queues should not be documented like remote AI inference, and remote AI inference should not be documented like local KV. The different testing guides are there to keep those truths visible.' + ] + } + ] + }, + { + id: 'copyable-helper-chooser', + title: 'Copy the smallest helper that matches the boundary', + paragraphs: [ + 'Pick the helper from the thing you need to prove. Use pure mocks for small functions, `createOfflineEnv()` when config-derived binding names matter, `createTestContext()` when the Worker surface matters, and skip-gated lanes when Docker/Podman or Cloudflare credentials are part of the test.' + ], + snippets: [ + { + title: 'Four helper lanes in one test file', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { + cf, + createMockEnv, + createOfflineEnv, + createTestContext, + env, + shouldSkip +} from 'devflare/test' +import config from '../devflare.config' + +test('pure binding logic uses a mock env', async () => { + const env = createMockEnv({ kv: { CACHE: 'CACHE' } }) + await env.CACHE.put('key', 'value') + expect(await env.CACHE.get('key')).toBe('value') +}) + +test('config-derived offline tests keep real binding names', () => { + const env = createOfflineEnv(config, { + secretsStore: { + API_TOKEN: 'test-token' + } + }) + + expect(env.API_TOKEN).toBeDefined() +}) + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('worker behavior uses the runtime-shaped harness', async () => { + const response = await cf.worker.get('/health') + expect(response.status).toBe(200) +}) + +const skipContainers = await shouldSkip.containers + +test.skipIf(skipContainers)('container tests are explicit opt-in lanes', async () => { + expect(skipContainers).toBe(false) +})` + } + ], + table: { + headers: ['Need to prove', 'Start with', 'Runs in ordinary CI?'], + rows: [ + [ + 'A pure function calls one binding method', + '`createMockEnv()` or a specific `createMock*` helper', + 'Yes' + ], + [ + 'The env should match `devflare.config.ts` without booting Miniflare', + '`createOfflineEnv()`', + 'Yes' + ], + [ + 'A Worker route, queue, scheduled, email, tail, or service flow works', + '`createTestContext()` plus `cf.*`', + 'Yes, unless the feature itself needs a remote boundary' + ], + [ + 'Docker/Podman, Cloudflare auth, or deployed behavior is the point', + '`shouldSkip.*` plus a separate integration lane', + 'Only when the runner has the dependency' + ] + ] + } + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/devflare/part-4.ts b/apps/documentation/src/lib/docs/content/devflare/part-4.ts new file mode 100644 index 0000000..447033c --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/part-4.ts @@ -0,0 +1,503 @@ +import type { DocPage } from '../../types' +import { + bindingTestingGuideCards, + bindingTestingGuideRows, + docsLink, + projectArchitectureFullSurfaceConfigCode, + projectArchitectureFullSurfaceDurableObjectCode, + projectArchitectureFullSurfaceQueueCode, + projectArchitectureFullSurfaceStructure, + projectArchitectureHostedAppConfigCode, + projectArchitectureHostedAppPackageCode, + projectArchitectureHostedAppStructure, + projectArchitectureHostedAppViteCode, + projectArchitectureMonorepoCommandsCode, + projectArchitectureMonorepoRootPackageCode, + projectArchitectureMonorepoStructure, + projectArchitectureMonorepoTurboCode, + projectArchitectureStarterConfigCode, + projectArchitectureStarterFetchCode, + projectArchitectureStarterPackageCode, + projectArchitectureStarterRouteCode, + projectArchitectureStarterStructure, + projectArchitectureSveltekitCase18ConfigCode, + testingFeelsNativeConfigCode, + testingFeelsNativeDurableObjectCode, + testingFeelsNativeStructure, + testingFeelsNativeTestCode, + testingFeelsNativeTransportCode, + testingFeelsNativeValueCode +} from './shared' + +export const devflareDocsPart4: DocPage[] = [ + { + slug: 'create-test-context', + group: 'Devflare', + navTitle: 'createTestContext()', + readTime: '6 min read', + eyebrow: 'Test harness', + title: 'Use `createTestContext()` and `cf.*` as the default runtime-shaped test harness', + summary: + 'Start tests with `createTestContext()` so the same config, bindings, routes, and handler surfaces the app uses in real runtime flows are available in Bun tests.', + description: + 'Devflareโ€™s recommended test story is not a pile of hand-built mocks. `createTestContext()` loads the nearest supported config, wires the local runtime surface, and gives you `cf.*` helpers that feel like the Worker entrypoints the app actually uses.', + highlights: [ + '`createTestContext()` autodiscovers the nearest supported config when you omit the path.', + 'It also autodiscovers conventional worker surfaces such as fetch, routes, queue, scheduled, email, and tail handlers.', + 'The helpers are runtime-shaped and context-accurate for handler logic, but they do not try to replay every internal Cloudflare dispatch detail byte for byte.', + '`cf.worker.fetch()` does not eagerly wait for all `waitUntil()` work, while queue, scheduled, and tail helpers do wait for their background work.', + '`src/transport.ts` stays optional and only matters when a local RPC-style bridge call under testโ€”most commonly a Durable Object method round-tripโ€”must preserve custom classes.' + ], + facts: [ + { + label: 'Best for', + value: 'Runtime-shaped tests that should stay close to the real worker surface' + }, + { label: 'Default harness', value: '`createTestContext()` plus `cf.*` helpers' }, + { + label: 'Optional extra', + value: + '`src/transport.ts` for custom class round-trips across local RPC-style bridge calls, especially Durable Object methods' + } + ], + sourcePages: [ + 'src/test/simple-context.ts', + 'src/test/simple-context-durable-objects.ts', + 'src/test/simple-context-paths.ts', + 'src/test/cf.ts', + 'src/test/tail.ts', + 'src/runtime/context.ts', + 'tests/integration/test-context/config-autodiscovery.test.ts' + ], + sections: [ + { + id: 'autodiscovery', + title: 'Let the harness discover the normal worker shape first', + paragraphs: [ + 'When you omit the config path, `createTestContext()` walks upward from the calling test file and finds the nearest supported config filename. It then autodetects the conventional worker surfaces that belong to that package instead of making you wire each one by hand.', + 'That is the main reason the built-in harness scales: the same config and file conventions keep working as the package gains routes, queues, scheduled handlers, inbound email, or tail handlers.' + ], + bullets: [ + 'Config path autodiscovery starts from the calling test file when you omit the argument.', + 'Conventional files such as `src/fetch.ts`, `src/routes/**`, `src/queue.ts`, `src/scheduled.ts`, `src/email.ts`, and `src/tail.ts` are discovered automatically when present.', + 'Service bindings and other config-driven runtime surfaces are discovered from the same authored config instead of a separate test-only schema.', + 'If a local RPC-style bridge call under test later needs custom class round-trips, the harness can also discover `src/transport.{ts,js,mts,mjs}` automatically.' + ] + }, + { + id: 'helper-behavior', + title: 'Know which helpers wait for background work and which do not', + table: { + headers: ['Helper', 'Current behavior'], + rows: [ + [ + '`cf.worker.fetch()`', + 'Returns when the handler resolves and does not eagerly wait for all `waitUntil()` work.' + ], + ['`cf.queue.trigger()`', 'Waits for queued background work before it returns.'], + ['`cf.scheduled.trigger()`', 'Waits for scheduled background work before it returns.'], + [ + '`cf.email.send()`', + 'In `createTestContext()` tests, directly invokes the configured local email handler and waits for its queued `waitUntil()` work; otherwise it falls back to the local email endpoint.' + ], + [ + '`cf.tail.trigger()`', + 'Works when `src/tail.ts` exists, supports a default or named `tail` export, and waits for the handler plus its `waitUntil()` work before it returns.' + ] + ] + }, + paragraphs: [ + 'These helpers are runtime-shaped and context-accurate for handler logic, but they do not try to recreate every internal Cloudflare dispatch step byte for byte. Their timing rules are documented explicitly instead of being left to guesswork.' + ], + callouts: [ + { + tone: 'warning', + title: 'Do not assert the wrong timing contract', + body: [ + 'If a test depends on `waitUntil()` side effects being complete, a plain `cf.worker.fetch()` assertion may be too early. Either assert the side effect directly or move that check into a higher-fidelity path.' + ] + } + ] + }, + { + id: 'tail-support', + title: 'Tail handlers are testable even before they become a public config lane', + paragraphs: [ + 'Tail support is already a real helper surface in the harness even though it still sits outside the public `files.*` config keys. When `createTestContext()` finds `src/tail.ts`, it wires `cf.tail.trigger()` automatically and runs the handler with the same runtime helper access as the other test surfaces.', + 'The handler can export a default function or a named `tail` function. The helper accepts either full trace items or smaller option objects through `cf.tail.create(...)`, then waits for the handler and any queued `waitUntil()` work before it returns.' + ], + snippets: [ + { + title: 'A tiny tail handler plus one honest harness test', + activeFile: 'tests/tail.test.ts', + structure: [ + { path: 'src', kind: 'folder' }, + { path: 'src/tail-state.ts' }, + { path: 'src/tail.ts' }, + { path: 'tests', kind: 'folder' }, + { path: 'tests/tail.test.ts' } + ], + files: [ + { + path: 'src/tail-state.ts', + language: 'ts', + code: String.raw`export const seenScripts: string[] = []` + }, + { + path: 'src/tail.ts', + language: 'ts', + code: String.raw`import type { TailEvent } from 'devflare/runtime' +import { seenScripts } from './tail-state' + +export async function tail({ events }: TailEvent): Promise { + for (const item of events) { + seenScripts.push(item.scriptName) + } +}` + }, + { + path: 'tests/tail.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' +import { seenScripts } from '../src/tail-state' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('tail handler sees trace items', async () => { + seenScripts.length = 0 + + const result = await cf.tail.trigger([ + cf.tail.create({ + scriptName: 'jobs-worker', + logs: [{ level: 'error', message: ['queue failed'], timestamp: Date.now() }] + }) + ]) + + expect(result.success).toBe(true) + expect(seenScripts).toEqual(['jobs-worker']) +})` + } + ] + } + ], + bullets: [ + 'Keep `src/tail.ts` as a conventional file for now; there is still no public `files.tail` config key.', + 'Use `cf.tail.create()` when the test only needs a few trace fields, and pass full trace items when the payload details are the point of the assertion.', + 'Reach for a higher-fidelity integration path when the question is Cloudflare ingress behavior rather than your own log or trace handling logic.' + ], + callouts: [ + { + tone: 'warning', + title: 'Supported helper, still a special-case surface', + body: [ + 'Tail support is real in the harness and runtime context model, but it is intentionally not documented like fetch, queue, scheduled, or email config yet because there is still no public `files.tail` key.' + ] + } + ] + }, + { + id: 'small-proof', + title: 'Start with one small proof test before layering helpers on top', + snippets: [ + { + title: 'A minimal runtime-shaped test', + filename: 'tests/worker.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +describe('worker runtime', () => { + test('routes through the built-in router', async () => { + const response = await cf.worker.get('/users/123') + expect(response.status).toBe(200) + }) +})` + } + ], + callouts: [ + { + tone: 'success', + title: 'Keep the first test boring', + body: [ + 'If the harness is working, you should be able to prove one route or handler path quickly before you hide it behind bigger factory helpers or shared test setup.' + ] + } + ] + }, + { + id: 'when-to-add-transport', + title: + 'Add `transport.ts` only when local RPC-style bridge calls in tests must preserve custom classes', + paragraphs: [ + 'Most `createTestContext()` tests do not need a transport file because strings, numbers, arrays, and plain JSON objects already cross the bridge naturally.', + 'Reach for `src/transport.ts` when a local RPC-style bridge call returns a real class instance and the caller needs that class again instead of a plain object. In practice that is most often a Durable Object method round-trip inside `createTestContext()`, not an ordinary HTTP response.' + ], + bullets: [ + 'Keep the encoded payload plain and JSON-friendly.', + 'Use one small transport entry per value type so decode rules stay reviewable.', + 'Set `files.transport: null` when you want to disable the convention explicitly for one package.' + ] + }, + { + id: 'where-to-go-next', + title: 'Know where to go when the harness is only part of the question', + cards: [ + { + href: docsLink('testing-overview'), + label: 'Testing', + meta: 'Map', + title: 'Testing overview', + body: 'Use the overview page when you are not sure whether the next question belongs to starter tests, binding-specific guides, runtime helpers, or CI.' + }, + { + href: docsLink('binding-testing-guides'), + label: 'Testing', + meta: 'Binding index', + title: 'Binding testing guides', + body: 'Jump straight to the binding-specific testing page when KV, D1, R2, Durable Objects, Queues, AI, or another binding needs a more specific test story.' + }, + { + href: docsLink('runtime-context'), + label: 'Runtime', + meta: 'Runtime helpers', + title: 'Runtime context', + body: 'Read this when getter failures, missing context, or proxy behavior are making the test harness harder to trace than it should be.' + }, + { + href: docsLink('testing-and-automation'), + label: 'Ship & operate', + meta: 'Automation', + title: 'Testing & automation', + body: 'Use the CI-facing page when the question becomes preview validation, workflow structure, or what should happen in automation instead of local tests.' + } + ], + callouts: [ + { + tone: 'info', + title: 'The harness is the center, not the whole map', + body: [ + '`createTestContext()` is the default test loop, but binding-specific caveats, runtime-context rules, and automation concerns still belong on their own pages.' + ] + } + ] + } + ] + }, + { + slug: 'transport-file', + group: 'Devflare', + navTitle: 'transport.ts', + readTime: '4 min read', + eyebrow: 'Runtime transport', + title: + 'Use `src/transport.ts` when local RPC-style bridge calls must round-trip custom classes cleanly', + summary: + 'Most workers do not need a transport file. Add one when Devflareโ€™s local RPC-style bridge must encode and decode custom values, especially across Durable Object method calls in tests.', + description: + '`src/transport.ts` is Devflareโ€™s custom serialization hook for local RPC-style bridge calls, especially the Durable Object round-trips Devflare manages in tests. It customizes the serialization layer for that bridge; it is not a replacement for ordinary fetch request or response handling. Its job is to let values that would otherwise collapse into plain JSON be rebuilt as real class instances on the caller side.', + highlights: [ + 'Use the conventional `src/transport.{ts,js,mts,mjs}` file or point `files.transport` at a custom path.', + 'The file must export a named `transport` object.', + 'Each transport entry needs an `encode` and `decode` pair.', + 'Set `files.transport: null` to disable autodiscovery explicitly.' + ], + facts: [ + { + label: 'Best for', + value: 'Bridge-backed Durable Object results that return custom classes' + }, + { label: 'Usually unnecessary', value: 'Strings, numbers, arrays, and plain JSON objects' }, + { label: 'Disable rule', value: '`files.transport: null`' } + ], + sourcePages: [ + 'src/test/simple-context.ts', + 'src/test/simple-context-durable-objects.ts', + 'src/test/simple-context-paths.ts', + 'src/dev-server/worker-surface-paths.ts', + 'src/config/schema-runtime.ts', + 'tests/integration/test-context/config-autodiscovery.test.ts' + ], + sections: [ + { + id: 'when-you-need-it', + title: 'Reach for it only when local RPC-style bridge calls must preserve real classes', + paragraphs: [ + 'Most workers do not need a transport file because plain data already crosses the bridge naturally.', + 'Add `src/transport.ts` when a local RPC-style bridge call returns a custom class instance and you want the caller to receive that class again instead of a plain object.' + ], + cards: [ + { + title: 'Good fit', + body: 'A Durable Object method or another Devflare-managed RPC boundary returns a small domain value like `Money`, `DoubleableNumber`, or another class with behavior you want to keep intact.' + }, + { + title: 'Usually unnecessary', + body: 'The handler or RPC call returns plain strings, numbers, arrays, or JSON objects that do not need custom decode logic.' + } + ], + callouts: [ + { + tone: 'info', + title: 'Think โ€œbridge-backed RPCโ€, not โ€œnormal JSON responsesโ€', + body: [ + 'This file matters when Devflare is proxying values across its local RPC bridge. It is not a replacement for ordinary Worker request or response serialization.' + ] + } + ] + }, + { + id: 'transport-shape', + title: 'Export one named `transport` object with small encode and decode pairs', + description: + 'Keep each entry boring and explicit: detect one value shape, encode it into plain data, and decode that data back into the class on the caller side.', + snippets: [ + { + title: 'Keep the transport file next to the class it knows how to round-trip', + description: + 'The transport file teaches Devflare how to turn a custom class into plain data for the bridge, then rebuild that class for the caller.', + activeFile: 'src/transport.ts', + structure: [ + { path: 'src', kind: 'folder' }, + { path: 'src/DoubleableNumber.ts' }, + { path: 'src/transport.ts' }, + { path: 'src/do.counter.ts' } + ], + files: [ + { + path: 'src/DoubleableNumber.ts', + language: 'ts', + focusLines: [[1, 10]], + code: String.raw`export class DoubleableNumber { + value: number + + constructor(value: number) { + this.value = value + } + + get double() { + return this.value * 2 + } +}` + }, + { + path: 'src/transport.ts', + language: 'ts', + focusLines: [[3, 8]], + code: String.raw`import { DoubleableNumber } from './DoubleableNumber' + +export const transport = { + DoubleableNumber: { + encode: (value: unknown) => + value instanceof DoubleableNumber ? value.value : false, + decode: (value: number) => new DoubleableNumber(value) + } +}` + }, + { + path: 'src/do.counter.ts', + language: 'ts', + focusLines: [[5, 8]], + code: String.raw`import { DoubleableNumber } from './DoubleableNumber' + +export class Counter { + private count = 0 + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +}` + } + ] + } + ], + bullets: [ + 'Return `false` or `undefined` from `encode` when the value is not a match.', + 'Keep the encoded payload plain and JSON-friendly.', + 'Use one transport key per value type so decoding stays obvious in code review.' + ] + }, + { + id: 'prove-it', + title: 'A tiny test is still the easiest proof of the round-trip', + snippets: [ + { + title: 'Test the round-trip, not just the numeric value', + filename: 'tests/counter.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' +import { DoubleableNumber } from '../src/DoubleableNumber' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('custom transport restores the class instance', async () => { + const result = await env.COUNTER.getByName('main').increment(2) + + expect(result).toBeInstanceOf(DoubleableNumber) + expect(result.value).toBe(2) + expect(result.double).toBe(4) +})` + } + ], + callouts: [ + { + tone: 'success', + title: 'Keep the first proof small', + body: [ + 'If the transport works, you should be able to prove it with one class, one method call, and one `instanceof` assertion before you hide it inside bigger helpers.' + ] + } + ] + }, + { + id: 'autodiscovery-rules', + title: 'Know the autodiscovery and disable rules', + bullets: [ + 'Use the conventional `src/transport.{ts,js,mts,mjs}` path when you want the default location.', + 'Use `files.transport` when the transport file lives somewhere else.', + 'Set `files.transport: null` when you want to disable the convention explicitly for a package.', + 'If the file exists but does not export a named `transport` object, Devflare warns and continues without custom transport decoding.' + ], + snippets: [ + { + title: 'Point at a custom transport path when the convention is not enough', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'transport-example', + files: { + fetch: 'src/fetch.ts', + transport: 'src/transport.ts' + } +})` + }, + { + title: 'Disable transport autodiscovery explicitly', + filename: 'devflare.config.ts', + language: 'ts', + code: String.raw`files: { + transport: null +}` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Do not treat the warning as success', + body: [ + 'If Devflare warns that the file does not export a named `transport` object, custom decode is off. The test may still run, but your class round-trip will not.' + ] + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/devflare/shared.ts b/apps/documentation/src/lib/docs/content/devflare/shared.ts new file mode 100644 index 0000000..cf0de1a --- /dev/null +++ b/apps/documentation/src/lib/docs/content/devflare/shared.ts @@ -0,0 +1,382 @@ +๏ปฟimport type { DocCodeTreeEntry, DocPage } from '../../types' + +import { bindingTestingGuides } from '../bindings' + +export const docsLink = (slug: string): string => `/docs/${slug}` + +export const bindingTestingGuideCards = bindingTestingGuides.map((guide) => ({ + href: docsLink(guide.testingSlug), + label: 'Binding guide', + meta: guide.defaultHarness, + title: `Testing ${guide.label}`, + body: `${guide.summary} Open the ${guide.label} overview first when you need the full binding story, or jump straight here when the only open question is how to test it.` +})) + +export const bindingTestingGuideRows = bindingTestingGuides.map((guide) => [ + guide.label, + guide.localStory, + guide.defaultHarness +]) + +export const testingFeelsNativeConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'counter-worker', + compatibilityDate: '2026-03-17', + files: { + durableObjects: 'src/do.counter.ts' + }, + bindings: { + durableObjects: { + COUNTER: { className: 'Counter', scriptName: 'do.counter.ts' } + } + } +})` + +export const testingFeelsNativeValueCode = String.raw`export class DoubleableNumber { + value: number + + constructor(value: number) { + this.value = value + } + + get double(): number { + return this.value * 2 + } +}` + +export const testingFeelsNativeTransportCode = String.raw`import { DoubleableNumber } from '../DoubleableNumber' + +export const transport = { + DoubleableNumber: { + encode: (value: unknown) => + value instanceof DoubleableNumber ? value.value : false, + decode: (value: number) => new DoubleableNumber(value) + } +}` + +export const testingFeelsNativeDurableObjectCode = String.raw`import { DoubleableNumber } from '../DoubleableNumber' + +export class Counter { + private count = 0 + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +}` + +export const testingFeelsNativeTestCode = String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' +import { DoubleableNumber } from '../src/DoubleableNumber' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('Durable Object methods feel native in tests', async () => { + const result = await env.COUNTER.getByName('main').increment(2) + + expect(result).toBeInstanceOf(DoubleableNumber) + expect(result.value).toBe(2) + expect(result.double).toBe(4) +})` + +export const testingFeelsNativeStructure: DocCodeTreeEntry[] = [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/DoubleableNumber.ts' }, + { path: 'src/transport.ts' }, + { path: 'src/do.counter.ts' }, + { path: 'tests', kind: 'folder' }, + { path: 'tests/counter.test.ts' }, + { path: 'env.d.ts', muted: true } +] + +export const projectArchitectureStarterStructure: DocCodeTreeEntry[] = [ + { path: 'package.json' }, + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/health.ts' }, + { path: 'tests', kind: 'folder' }, + { path: 'tests/fetch.test.ts' }, + { path: 'env.d.ts', muted: true }, + { path: '.devflare/wrangler.jsonc', muted: true }, + { path: '.wrangler/deploy/config.json', muted: true } +] + +export const projectArchitectureStarterPackageCode = String.raw`{ + "name": "notes-api", + "private": true, + "type": "module", + "scripts": { + "types": "bunx --bun devflare types", + "dev": "bunx --bun devflare dev", + "build": "bunx --bun devflare build", + "deploy": "bunx --bun devflare deploy" + }, + "devDependencies": { + "devflare": "workspace:*" + } +}` + +export const projectArchitectureStarterConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +})` + +export const projectArchitectureStarterFetchCode = String.raw`import { sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId)` + +export const projectArchitectureStarterRouteCode = String.raw`export async function GET(): Promise { + return Response.json({ ok: true }) +}` + +export const projectArchitectureFullSurfaceStructure: DocCodeTreeEntry[] = [ + { path: 'package.json' }, + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/index.ts' }, + { path: 'src/routes/uploads', kind: 'folder' }, + { path: 'src/routes/uploads/[name].ts' }, + { path: 'src/queue.ts' }, + { path: 'src/scheduled.ts' }, + { path: 'src/email.ts' }, + { path: 'src/do', kind: 'folder' }, + { path: 'src/do/session-room.ts' }, + { path: 'src/ep', kind: 'folder' }, + { path: 'src/ep/admin.ts' }, + { path: 'src/workflows', kind: 'folder' }, + { path: 'src/workflows/rebuild-search.ts' }, + { path: 'src/transport.ts' }, + { path: 'tests', kind: 'folder' }, + { path: 'tests/worker.test.ts' }, + { path: 'env.d.ts', muted: true } +] + +export const projectArchitectureFullSurfaceConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workspace-app', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts', + entrypoints: 'src/ep/**/*.ts', + workflows: 'src/workflows/**/*.ts', + transport: 'src/transport.ts' + }, + bindings: { + durableObjects: { + SESSION_ROOM: 'SessionRoom' + }, + queues: { + producers: { + EMAILS: 'workspace-emails' + }, + consumers: [ + { + queue: 'workspace-emails' + } + ] + } + }, + triggers: { + crons: ['0 */6 * * *'] + } +})` + +export const projectArchitectureFullSurfaceQueueCode = String.raw`import type { QueueEvent } from 'devflare/runtime' + +export async function queue({ messages }: QueueEvent): Promise { + for (const message of messages) { + console.log('processing job', message.id) + } +}` + +export const projectArchitectureFullSurfaceDurableObjectCode = String.raw`import { DurableObject } from 'cloudflare:workers' + +${'export'} ${'class'} ${'SessionRoom'} extends DurableObject { + async fetch(request: Request): Promise { + return new Response('room:' + new URL(request.url).pathname) + } +}` + +export const projectArchitectureHostedAppStructure: DocCodeTreeEntry[] = [ + { path: 'apps/documentation', kind: 'folder' }, + { path: 'apps/documentation/package.json' }, + { path: 'apps/documentation/devflare.config.ts' }, + { path: 'apps/documentation/vite.config.ts' }, + { path: 'apps/documentation/svelte.config.js' }, + { path: 'apps/documentation/src', kind: 'folder' }, + { path: 'apps/documentation/src/routes', kind: 'folder' }, + { path: 'apps/documentation/src/routes/+layout.svelte' }, + { path: 'apps/documentation/static', kind: 'folder' }, + { path: 'apps/documentation/static/devflare-logo.svg' }, + { path: 'apps/documentation/.adapter-cloudflare/_worker.js', muted: true }, + { path: 'apps/documentation/.devflare/wrangler.jsonc', muted: true } +] + +export const projectArchitectureHostedAppPackageCode = String.raw`{ + "name": "documentation", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run llm:generate && bunx --bun devflare dev", + "build": "bun run llm:generate && bunx --bun devflare build", + "deploy": "bun run llm:generate && bunx --bun devflare deploy", + "types": "bunx --bun devflare types" + }, + "devDependencies": { + "devflare": "workspace:*", + "vite": "^8", + "@sveltejs/kit": "^2" + } +}` + +export const projectArchitectureHostedAppConfigCode = String.raw`import { defineConfig } from '../../packages/devflare/src/config-entry' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: 'devflare-docs', + compatibilityDate: '2026-04-08', + files: { + fetch: false + }, + previews: { + includeCrons: false + }, + accountId, + assets: { + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }, + wrangler: { + passthrough: { + main: '.adapter-cloudflare/_worker.js' + } + } +})` + +export const projectArchitectureHostedAppViteCode = String.raw`import { sveltekit } from '@sveltejs/kit/vite' +import { devflarePlugin } from '../../packages/devflare/src/vite/index' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + devflarePlugin(), + sveltekit() + ] +})` + +export const projectArchitectureSveltekitCase18ConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case18-sveltekit-full', + files: { + fetch: '.svelte-kit/cloudflare/_worker.js', + durableObjects: 'src/do.*.ts', + transport: 'src/transport.ts' + }, + bindings: { + r2: { + IMAGES: 'images-bucket' + }, + d1: { + DB: 'main-db' + }, + durableObjects: { + CHAT_ROOM: { + className: 'ChatRoom' + } + } + } +})` + +export const projectArchitectureMonorepoStructure: DocCodeTreeEntry[] = [ + { path: 'package.json' }, + { path: 'turbo.json' }, + { path: 'apps', kind: 'folder' }, + { path: 'apps/documentation', kind: 'folder' }, + { path: 'apps/documentation/devflare.config.ts' }, + { path: 'apps/testing', kind: 'folder' }, + { path: 'apps/testing/devflare.config.ts' }, + { path: 'apps/testing/workers', kind: 'folder' }, + { path: 'apps/testing/workers/auth-service', kind: 'folder' }, + { path: 'apps/testing/workers/auth-service/devflare.config.ts' }, + { path: 'packages', kind: 'folder' }, + { path: 'packages/devflare', kind: 'folder' }, + { path: 'cases', kind: 'folder' }, + { path: 'cases/case5', kind: 'folder' }, + { path: 'cases/case5/devflare.config.ts' }, + { path: 'cases/case5/math-service', kind: 'folder' }, + { path: 'cases/case5/math-service/devflare.config.ts' } +] + +export const projectArchitectureMonorepoRootPackageCode = String.raw`{ + "name": "devflare-monorepo", + "private": true, + "workspaces": [ + "apps/*", + "apps/testing/workers/*", + "packages/*", + "cases/*" + ], + "scripts": { + "devflare:build": "turbo run build --filter=devflare --filter=documentation", + "devflare:test": "turbo run test --filter=...devflare", + "devflare:check": "turbo run check --filter=documentation", + "devflare:ci": "bun run devflare:build && bun run devflare:test && bun run devflare:check" + } +}` + +export const projectArchitectureMonorepoTurboCode = String.raw`{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".devflare/**", ".wrangler/deploy/**", "env.d.ts"] + }, + "test": { + "dependsOn": ["^build", "transit"] + }, + "check": { + "dependsOn": ["^build", "transit"] + } + } +}` + +export const projectArchitectureMonorepoCommandsCode = String.raw`# repo-root orchestration +bun run turbo build --filter=documentation +bun run devflare:check + +# package-local deploy +cd apps/documentation +bun run deploy -- --preview next + +# sidecar worker family +cd ../testing/workers/auth-service +bunx --bun devflare deploy --preview pr-123` diff --git a/apps/documentation/src/lib/docs/content/examples.ts b/apps/documentation/src/lib/docs/content/examples.ts new file mode 100644 index 0000000..cf01273 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/examples.ts @@ -0,0 +1 @@ +export { examplesDocs } from './examples/index' diff --git a/apps/documentation/src/lib/docs/content/examples/index.ts b/apps/documentation/src/lib/docs/content/examples/index.ts new file mode 100644 index 0000000..99f2939 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/examples/index.ts @@ -0,0 +1,4 @@ +import { examplesDocsPart1 } from './part-1' +import { examplesDocsPart2 } from './part-2' + +export const examplesDocs = [examplesDocsPart1, examplesDocsPart2].flat() diff --git a/apps/documentation/src/lib/docs/content/examples/part-1.ts b/apps/documentation/src/lib/docs/content/examples/part-1.ts new file mode 100644 index 0000000..a6b9203 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/examples/part-1.ts @@ -0,0 +1,142 @@ +import type { DocPage } from '../../types' +import { featureRows, workerOnlyRecipeFiles } from './shared' + +export const examplesDocsPart1: DocPage[] = [ + { + slug: 'first-route-tree', + group: 'Quickstart', + navTitle: 'Your first route tree', + readTime: '5 min read', + eyebrow: 'Routing recipe', + title: 'Move from one fetch file to `src/routes/**` without adding binding noise', + summary: + 'The first route-tree step should only change project shape: config, request-wide middleware, one route, and one worker-level test.', + description: + 'Do this before adding storage or remote services. It teaches the authored file shape and the route dispatch contract while the app is still small enough to debug by sight.', + highlights: [ + 'Add `files.routes.dir` in config.', + 'Keep request-wide middleware in `src/fetch.ts`.', + 'Put URL-specific handlers in `src/routes/**`.', + 'Test through `cf.worker` so route dispatch is part of the proof.' + ], + facts: [ + { label: 'Best for', value: 'The first growth step after `first-worker`' }, + { + label: 'Files', + value: '`devflare.config.ts`, `src/fetch.ts`, `src/routes/**`, `tests/worker.test.ts`' + }, + { label: 'Proof', value: '`cf.worker.get()` exercises route dispatch' } + ], + sourcePages: [ + 'apps/documentation/src/lib/docs/content/examples.ts', + 'cases/case8/*', + 'packages/devflare/src/runtime/router/index.ts' + ], + sections: [ + { + id: 'copyable-route-tree', + title: 'Copy the route tree shape', + snippets: [ + { + title: 'Worker-only route tree with one test', + description: + 'These files are enough to move out of a single fetch handler while keeping the runtime and test story honest.', + activeFile: 'src/routes/notes/[id].ts', + files: workerOnlyRecipeFiles + } + ] + }, + { + id: 'common-failure', + title: 'Common failure messages', + table: { + headers: ['Symptom', 'Likely fix'], + rows: [ + [ + '`404 Not Found` for a route file', + 'Check `files.routes.dir`, the route filename, and any configured prefix.' + ], + [ + 'Ambiguous two-argument handler error', + 'Wrap the handler with `defineFetchHandler(..., { style })` or use an event-first signature.' + ], + [ + '`env.dispose` is not a function', + 'Import `env` from `devflare/test` in tests, not from `devflare/runtime`.' + ] + ] + } + } + ] + }, + { + slug: 'feature-index', + group: 'Guides', + navTitle: 'Feature index', + readTime: '6 min read', + eyebrow: 'Support matrix', + title: 'Scan local, remote, test, preview, and docs support in one table', + summary: + 'This page is the compact feature support index that keeps support level, Cloudflare boundary, test helper, preview lifecycle, and docs links in one place.', + description: + 'Use the feature index when you already know the feature name and need to decide whether the next proof belongs in pure unit tests, `createTestContext`, a Docker/Podman lane, or a Cloudflare-authenticated remote lane.', + highlights: [ + 'Support is reduced to `Full` or `Remote` so the boundary is easy to scan.', + 'Test helpers are named explicitly so examples are easy to copy.', + 'Preview lifecycle says whether Devflare manages resources, reports warnings, or leaves ownership to the product.', + 'The docs integrity suite snapshots this table so support claims cannot drift silently.' + ], + facts: [ + { label: 'Best for', value: 'Support stance lookup' }, + { label: 'Snapshot source', value: '`featureRows` in docs content' }, + { + label: 'Remote rule', + value: 'Remote-only behavior gets `shouldSkip.*` or a dedicated deploy smoke test' + } + ], + sourcePages: [ + 'apps/documentation/src/lib/docs/content/examples.ts', + 'packages/devflare/src/test/offline-bindings.ts', + 'packages/devflare/src/test/should-skip.ts' + ], + sections: [ + { + id: 'matrix', + title: 'Feature support matrix', + snippets: [ + { + title: 'Use the matrix to pick a local proof lane', + filename: 'tests/cache.test.ts', + language: 'ts', + code: String.raw`import { describe, expect, test } from 'bun:test' +import { createOfflineEnv } from 'devflare/test' + +describe('feature support matrix choice', () => { + test('KV can be proven with an offline binding fixture', async () => { + const env = createOfflineEnv({ + kv: ['CACHE'] + }) + + await env.CACHE.put('feature:homepage', 'enabled') + + expect(await env.CACHE.get('feature:homepage')).toBe('enabled') + }) +})` + } + ], + table: { + layout: 'wide', + headers: [ + 'Feature', + 'Support', + 'Cloudflare boundary', + 'Test helper', + 'Preview lifecycle', + 'Docs' + ], + rows: featureRows + } + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/examples/part-2.ts b/apps/documentation/src/lib/docs/content/examples/part-2.ts new file mode 100644 index 0000000..d3cc298 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/examples/part-2.ts @@ -0,0 +1,626 @@ +import type { DocPage } from '../../types' +import { + caseLink, + docsLink, + durableObjectRecipeFiles, + featureRows, + offlineRecipeFiles, + previewRecipeFiles, + queueRecipeFiles, + recipeRows, + serviceBindingRecipeFiles, + storageRecipeFiles, + svelteKitRecipeFiles, + workerOnlyRecipeFiles +} from './shared' + +export const examplesDocsPart2: DocPage[] = [ + { + slug: 'runtime-handler-styles', + group: 'Devflare', + navTitle: 'Handler styles', + readTime: '6 min read', + eyebrow: 'Runtime', + title: 'Use event-first handlers by default and mark ambiguous handler styles explicitly', + summary: + 'Devflare runtime supports event-first handlers, request-wide `sequence()` middleware, route method handlers, and explicit markers for ambiguous two-argument worker-style or resolve-style functions.', + description: + 'This page documents `defineFetchHandler`, `sequence`, `markResolveStyle`, `markWorkerStyle`, event-first handlers, and route dispatch with examples that match the actual `devflare/runtime` exports.', + highlights: [ + 'Event-first handlers are the least ambiguous shape.', + 'Use `sequence()` for request-wide middleware.', + 'Use route files for method-specific leaves.', + 'Wrap two-argument handlers with `defineFetchHandler(..., { style })` or a marker.' + ], + facts: [ + { label: 'Best for', value: 'Runtime import and dispatch questions' }, + { label: 'Worker-safe import', value: '`devflare/runtime`' }, + { label: 'Ambiguous case', value: 'Two-argument fetch handlers' } + ], + sourcePages: [ + 'packages/devflare/src/runtime/middleware.ts', + 'packages/devflare/src/runtime/router/index.ts' + ], + sections: [ + { + id: 'copyable-styles', + title: 'Copy the handler style that matches the job', + snippets: [ + { + title: 'Event-first and route-dispatch examples', + activeFile: 'src/fetch.ts', + files: [ + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { defineFetchHandler, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId) + +export const fetch = defineFetchHandler( + (request: Request, env: DevflareEnv) => env.ASSETS.fetch(request), + { style: 'worker' } +)` + }, + { + path: 'src/routes/health.ts', + language: 'ts', + code: String.raw`export function GET(): Response { + return Response.json({ ok: true }) +} + +export function POST(): Response { + return new Response(null, { status: 204 }) +}` + }, + { + path: 'src/legacy.ts', + language: 'ts', + code: String.raw`import { markResolveStyle, markWorkerStyle } from 'devflare/runtime' + +export const resolveStyle = markResolveStyle(async (event, resolve) => { + return resolve(event) +}) + +export const workerStyle = markWorkerStyle((request, env) => { + return env.ASSETS.fetch(request) +})` + } + ] + } + ], + callouts: [ + { + tone: 'warning', + title: 'The ambiguous error is intentional', + body: [ + 'If a two-argument handler is not marked, Devflare cannot safely know whether it is `(event, resolve)` or `(request, env)`. Mark it instead of relying on parameter names.' + ] + } + ] + } + ] + }, + { + slug: 'test-helper-reference', + group: 'Devflare', + navTitle: 'Test helper reference', + readTime: '8 min read', + eyebrow: 'Testing', + title: 'Document every public `devflare/test` helper by the smallest useful use', + summary: + 'Use this reference when you know you need the test package but not which helper surface is the smallest truthful proof.', + description: + 'The `devflare/test` entrypoint intentionally has multiple lanes: runtime-shaped tests, direct event helpers, pure mocks, offline envs, remote-boundary guards, and Docker/Podman-gated container helpers.', + highlights: [ + '`createTestContext`, `cf`, and `env.dispose` are the default worker-shaped lane.', + 'Pure mocks are for small units where runtime dispatch is not the question.', + 'Remote and container helpers must be skip-gated.', + 'Internal/advanced helpers are marked as such instead of being hidden in prose.' + ], + facts: [ + { label: 'Best for', value: 'Choosing and importing test helpers' }, + { + label: 'Default import', + value: '`import { cf, createTestContext, env } from "devflare/test"`' + }, + { label: 'Cleanup', value: '`afterAll(() => env.dispose())`' } + ], + sourcePages: [ + 'packages/devflare/src/test/index.ts', + 'packages/devflare/src/test/cf.ts', + 'packages/devflare/src/test/utilities.ts', + 'packages/devflare/src/test/offline-bindings.ts', + 'packages/devflare/src/test/containers.ts' + ], + sections: [ + { + id: 'helper-table', + title: 'Helper map', + table: { + headers: ['Export family', 'Smallest use', 'Status'], + rows: [ + [ + '`createTestContext`, `env`, `cf`', + 'Runtime-shaped Worker tests with cleanup.', + 'Recommended' + ], + [ + '`cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, `cf.tail`', + 'Trigger the matching Worker surface directly.', + 'Recommended' + ], + [ + '`worker`, `queue`, `scheduled`, `email`, `tail`', + 'Direct helper modules behind the unified `cf` API.', + 'Advanced' + ], + [ + '`createOfflineEnv`, `createOfflineBindings`, `describeOfflineSupport`, `getOfflineSupportMatrix`', + 'Pure config-derived binding fixtures without runtime startup.', + 'Recommended for offline-first unit tests' + ], + [ + '`createMockKV`, `createMockD1`, `createMockR2`, `createMockQueue`, `createMockEnv`', + 'Small pure unit tests without Miniflare.', + 'Recommended when runtime dispatch is irrelevant' + ], + [ + '`createMockRateLimit`, `createMockVersionMetadata`, `createMockWorkerLoader`, `createMockSecretsStoreSecret`', + 'Pure fixture for one platform-shaped binding.', + 'Recommended' + ], + [ + '`createMockMTLSCertificate`, `createMockDispatchNamespace`, `createMockWorkflow`, `createMockPipeline`', + 'Call-shape tests for platform-owned products.', + 'Boundary-aware' + ], + [ + '`createMockImagesBinding`, `createMockMediaBinding`, `createMockArtifacts`, AI Search mocks', + 'Deterministic local tests for product-shaped APIs.', + 'Boundary-aware' + ], + [ + '`shouldSkip`', + 'Skip remote, paid, or dependency-heavy checks clearly.', + 'Recommended for CI' + ], + [ + '`containers`, `createContainerManager`, `detectContainerEngine`, `getContainerSkipReason`, `stopActiveContainers`', + 'Docker/Podman-gated local container tests.', + 'Integration lane' + ], + [ + '`resolveServiceBindings`, `resolveDOBindings`, `clearBundleCache`', + 'Service-binding and cross-worker DO resolution internals.', + 'Advanced/internal' + ] + ] + } + }, + { + id: 'exact-export-index', + title: 'Exact value export index', + table: { + headers: ['Export', 'Use'], + rows: [ + ['`createTestContext`', 'Boot the nearest Devflare config in the test harness.'], + ['`env`', 'Read bindings and call `env.dispose()` in harness tests.'], + ['`cf`', 'Unified Worker, queue, scheduled, email, and tail trigger API.'], + ['`worker`', 'Direct Worker fetch helper behind `cf.worker`.'], + ['`queue`', 'Direct queue helper behind `cf.queue`.'], + ['`scheduled`', 'Direct scheduled helper behind `cf.scheduled`.'], + ['`email`', 'Direct email helper behind `cf.email`.'], + ['`tail`', 'Direct tail helper behind `cf.tail`.'], + [ + '`shouldSkip`', + 'Skip Cloudflare-auth, paid, remote, or local engine tests explicitly.' + ], + ['`containers`', 'Default Docker/Podman-backed container manager.'], + ['`createContainerManager`', 'Create an isolated container manager for tests.'], + ['`detectContainerEngine`', 'Check whether Docker or Podman can run.'], + ['`getContainerSkipReason`', 'Explain why a container test should skip.'], + ['`stopActiveContainers`', 'Stop containers after tests finish.'], + ['`createOfflineBindings`', 'Build pure binding fixtures from config.'], + ['`createOfflineEnv`', 'Build an env object for offline-first unit tests.'], + ['`describeOfflineSupport`', 'Read one binding family support stance.'], + ['`getOfflineSupportMatrix`', 'Read the full offline support stance map.'], + ['`createMockAISearchInstance`', 'Mock one AI Search instance.'], + ['`createMockAISearchNamespace`', 'Mock an AI Search namespace.'], + ['`createMockTestContext`', 'Pure test context helper for small units.'], + ['`withTestContext`', 'Scope a pure context to one callback.'], + ['`createMockKV`', 'Mock KV for pure units.'], + ['`createMockD1`', 'Mock D1 for pure units.'], + ['`createMockR2`', 'Mock R2 for pure units.'], + ['`createMockQueue`', 'Mock a Queue producer.'], + ['`createMockRateLimit`', 'Mock Rate Limiting.'], + ['`createMockVersionMetadata`', 'Mock Version Metadata.'], + ['`createMockWorkerLoader`', 'Mock Worker Loaders.'], + ['`createMockMTLSCertificate`', 'Mock an mTLS fetcher.'], + ['`createMockDispatchNamespace`', 'Mock a dispatch namespace.'], + ['`createMockWorkflow`', 'Mock a Workflow binding.'], + ['`createMockPipeline`', 'Mock a Pipelines binding.'], + ['`createMockImagesBinding`', 'Mock Images chains.'], + ['`createMockMediaBinding`', 'Mock Media Transformation chains.'], + ['`createMockArtifacts`', 'Mock Artifacts repo APIs.'], + ['`createMockSecretsStoreSecret`', 'Mock a Secrets Store secret.'], + ['`createMockEnv`', 'Create a pure env with selected mock bindings.'], + ['`hasServiceBindings`', 'Advanced/internal service-binding resolution predicate.'], + ['`resolveServiceBindings`', 'Advanced/internal service-binding resolution.'], + ['`hasCrossWorkerDOs`', 'Advanced/internal cross-worker Durable Object predicate.'], + ['`resolveDOBindings`', 'Advanced/internal Durable Object binding resolution.'], + ['`clearBundleCache`', 'Advanced/internal resolver cache reset for tests.'] + ] + } + }, + { + id: 'copyable-helper', + title: 'Copy the default helper shape', + snippets: [ + { + title: 'Worker, event, offline, and boundary tests', + activeFile: 'tests/worker.test.ts', + files: offlineRecipeFiles + } + ] + }, + { + id: 'failure-messages', + title: 'Expected failure and skip behavior', + table: { + headers: ['Failure or skip', 'Meaning', 'Fix'], + rows: [ + [ + '`No devflare config found`', + '`createTestContext()` could not discover a supported config from the test file.', + 'Pass the config path or move the test under the package root.' + ], + [ + '`env.dispose is not a function`', + 'The test imported the runtime env proxy instead of the test env.', + 'Use `import { env } from "devflare/test"` in tests.' + ], + [ + '`shouldSkip.ai` is true', + 'Cloudflare auth or remote AI prerequisites are missing.', + 'Keep the test skipped in local/CI, or enable remote mode in a dedicated lane.' + ], + [ + '`shouldSkip.containers` is true', + 'Docker/Podman is missing or not usable in this runner.', + 'Install an engine or keep container tests in an optional integration job.' + ] + ] + } + } + ] + }, + { + slug: 'deploy-command-recipes', + group: 'Ship & operate', + navTitle: 'Deploy recipes', + readTime: '7 min read', + eyebrow: 'Deploy', + title: 'Run deploy commands as explicit recipes with expected files and effects', + summary: + 'Use build, dry-run, production deploy, named preview deploy, same-worker preview upload, cleanup, and GitHub Actions as separate recipes with visible effects.', + description: + 'Deploy docs should start from commands a developer can copy and the artifacts or remote effects they should expect, then move caveats into boundary notes after the working recipe.', + highlights: [ + '`build` writes local artifacts and does not deploy.', + '`deploy --prod` is production; `deploy --preview ` is a named preview scope.', + 'Plain `--preview` and named `--preview ` are different preview strategies.', + 'GitHub workflows should be minimal first and policy-heavy later.' + ], + facts: [ + { label: 'Best for', value: 'Shipping without guessing target or cleanup behavior' }, + { label: 'Local artifacts', value: '`.devflare/**` and `.wrangler/deploy/**`' }, + { + label: 'Remote effects', + value: 'Only deploy commands with explicit targets touch Cloudflare' + } + ], + sourcePages: [ + 'packages/devflare/src/cli/commands/deploy.ts', + '.github/workflows/preview.yml', + '.github/actions/devflare-deploy/action.yml' + ], + sections: [ + { + id: 'commands', + title: 'Command recipes', + table: { + headers: ['Task', 'Command', 'Expected result'], + rows: [ + [ + 'Build local artifacts', + '`bunx --bun devflare build --env production`', + 'Writes deploy-ready generated output; does not touch Cloudflare.' + ], + [ + 'Inspect compiled config', + '`bunx --bun devflare config print --format wrangler`', + 'Prints Wrangler-facing config for review.' + ], + [ + 'Dry-run production deploy', + '`bunx --bun devflare deploy --prod --dry-run`', + 'Exercises deploy planning without uploading.' + ], + [ + 'Production deploy', + '`bunx --bun devflare deploy --prod`', + 'Uploads to the stable production Worker name.' + ], + [ + 'Same-worker preview upload', + '`bunx --bun devflare deploy --preview`', + 'Uses Cloudflare same-worker preview behavior and synthetic preview scope.' + ], + [ + 'Named preview scope', + '`bunx --bun devflare deploy --preview pr-123`', + 'Uses explicit preview scope for resource naming, logs, and cleanup.' + ], + [ + 'Inspect preview bindings', + '`bunx --bun devflare previews bindings --scope pr-123`', + 'Shows resolved preview resources and worker references.' + ], + [ + 'Clean preview resources', + '`bunx --bun devflare previews cleanup --scope pr-123 --apply`', + 'Deletes preview-owned resources and dedicated preview workers when applicable.' + ] + ] + } + }, + { + id: 'preview-models', + title: 'Same-worker preview vs named preview scope', + table: { + headers: ['Model', 'Use when', 'Tiny example'], + rows: [ + [ + 'Same-worker preview', + 'You want Cloudflare preview upload behavior and do not need a human-named resource scope.', + '`devflare deploy --preview`' + ], + [ + 'Named preview scope', + 'You want logs, resource names, cleanup, and GitHub feedback tied to a visible name.', + '`devflare deploy --preview pr-123`' + ], + [ + 'Branch-scoped worker family', + 'Durable Objects, queues, crons, or service topology need stronger isolation.', + '`preview.scope()` plus dedicated preview worker naming' + ] + ] + } + }, + { + id: 'lifecycle', + title: 'Preview resource lifecycle by feature', + table: { + headers: ['Feature', 'Lifecycle stance'], + rows: [ + [ + 'KV, D1, R2, Queues, Vectorize', + 'Can be preview-scoped and managed when authored with preview-aware names.' + ], + [ + 'Services and Durable Objects', + 'Worker naming and migrations require explicit preview strategy; cleanup can remove preview-only workers.' + ], + [ + 'Analytics Engine and Browser Rendering', + 'Reported as warnings because there is no ordinary account resource to delete.' + ], + [ + 'Hyperdrive', + 'Cleanup can remove existing preview configs, but database ownership stays product-owned.' + ], + [ + 'AI, Images, Media, Containers', + 'Product-owned remote behavior; use smoke tests and usage limits rather than pretending local cleanup owns the product.' + ] + ] + } + }, + { + id: 'github', + title: 'Minimal GitHub Actions preview workflow', + snippets: [ + { + title: 'Preview workflow', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: previewRecipeFiles[1].code + } + ] + } + ] + }, + { + slug: 'docs-release-gates', + group: 'Ship & operate', + navTitle: 'Docs release gates', + readTime: '6 min read', + eyebrow: 'Verification', + title: 'Make documentation changes part of public API changes', + summary: + 'Public exports, schema keys, compiler output, typegen, CLI commands, test helpers, and support stances should fail CI when the docs do not change with them.', + description: + 'This is the maintainer checklist for keeping the docs from becoming a prose archive again. The tests cover drift; the manual QA checklist covers developer paths a test cannot fully feel.', + highlights: [ + 'Docs integrity tests parse snippets and check API, schema, CLI, cases, source metadata, feature matrix, and generated `LLM.md` drift.', + 'Package publish should regenerate `packages/devflare/LLM.md` from the docs model.', + 'Manual QA follows five paths: new user, binding, test, deploy, and remote boundary.', + 'The checklist is intentionally short so it is used.' + ], + facts: [ + { label: 'Best for', value: 'Release and review checklists' }, + { label: 'Main command', value: '`bun run devflare:docs-integrity`' }, + { label: 'Generated file', value: '`packages/devflare/LLM.md` must match the docs model' } + ], + sourcePages: [ + 'packages/devflare/tests/unit/docs/documentation-integrity.test.ts', + 'packages/devflare/scripts/generate-llm.ts', + 'apps/documentation/src/lib/docs/llm.ts' + ], + sections: [ + { + id: 'docs-must-change', + title: 'Docs must change when these public surfaces change', + table: { + headers: ['Changed surface', 'Docs or test that must move'], + rows: [ + ['Public exports', 'Package entrypoint table and export drift test.'], + [ + 'Config schema keys or binding compiler output', + 'Binding guide manifest and schema coverage test.' + ], + ['Typegen output', 'Generated types docs and first binding examples.'], + ['CLI commands or help pages', 'CLI docs and command table drift test.'], + ['`devflare/test` helpers', '`test-helper-reference` and helper coverage checks.'], + [ + 'Cloudflare support stance', + '`feature-index`, binding pages, and support matrix snapshot.' + ], + [ + 'Docs content model', + 'Regenerate `packages/devflare/LLM.md` and pass generated handbook drift check.' + ] + ] + } + }, + { + id: 'manual-qa', + title: 'Final manual QA checklist', + snippets: [ + { + title: 'Wire the docs gate into a CI job', + filename: '.github/workflows/docs.yml', + language: 'yaml', + code: String.raw`name: docs + +on: + pull_request: + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run --cwd apps/documentation check + - run: bun run devflare:docs-integrity` + } + ], + bullets: [ + 'New user path: `first-worker` -> `first-unit-test` -> `first-route-tree` works as a narrative.', + 'Binding path: `first-bindings` -> one binding page -> matching testing guide.', + 'Test path: `test-helper-reference` names the smallest helper and cleanup pattern.', + 'Deploy path: `deploy-command-recipes` distinguishes build, dry-run, prod, preview, and cleanup.', + 'Remote-boundary path: `feature-index` and binding pages make auth, Docker/Podman, paid services, and skips explicit.' + ] + } + ] + }, + { + slug: 'bridge-architecture-internals', + group: 'Devflare', + navTitle: 'Bridge internals', + sidebarHidden: true, + readTime: '3 min read', + eyebrow: 'Internal architecture', + title: 'Keep bridge architecture documentation behind advanced/internal links', + summary: + 'The bridge architecture document remains valuable, but it should not be on the first-hour developer path.', + description: + 'Link the bridge architecture doc only from advanced runtime, transport, or maintainer pages. Beginner docs should show recipes first and link internals after the developer already has a working example.', + highlights: [ + 'The architecture doc stays preserved.', + 'Internal transport details are linked from advanced docs only.', + 'Beginner pages should not require bridge knowledge before the first route, binding, or test works.' + ], + facts: [ + { label: 'Canonical file', value: '`packages/devflare/.docs/BRIDGE_ARCHITECTURE.md`' }, + { label: 'Audience', value: 'Maintainers and advanced runtime debugging' }, + { label: 'Linked from', value: 'Transport and project architecture docs' } + ], + sourcePages: ['packages/devflare/.docs/BRIDGE_ARCHITECTURE.md'], + sections: [ + { + id: 'when-to-read', + title: 'Read this after the recipe path works', + snippets: [ + { + title: 'A bridge-backed value that needs a transport file', + activeFile: 'src/transport.ts', + files: [ + { + path: 'src/domain/Money.ts', + language: 'ts', + code: String.raw`export class Money { + constructor( + readonly amount: number, + readonly currency: string + ) {} + + format(): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: this.currency + }).format(this.amount) + } +}` + }, + { + path: 'src/transport.ts', + language: 'ts', + code: String.raw`import { Money } from './domain/Money' + +export const transport = { + Money: { + encode: (value: unknown) => + value instanceof Money + ? { amount: value.amount, currency: value.currency } + : false, + decode: (value: { amount: number; currency: string }) => + new Money(value.amount, value.currency) + } +}` + }, + { + path: 'src/do/invoices.ts', + language: 'ts', + code: String.raw`import { Money } from '../domain/Money' + +export class Invoices extends DurableObject { + async total(customerId: string): Promise { + const key = 'invoice:' + customerId + ':total' + const stored = await this.ctx.storage.get(key) + return new Money(stored ?? 0, 'USD') + } +}` + } + ] + } + ], + bullets: [ + 'You are debugging the bridge transport or local runtime startup.', + 'You are changing how local RPC, Durable Objects, service bindings, or framework platform glue cross the worker boundary.', + 'You need maintainer context, not first-run setup instructions.' + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/examples/shared.ts b/apps/documentation/src/lib/docs/content/examples/shared.ts new file mode 100644 index 0000000..43b5196 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/examples/shared.ts @@ -0,0 +1,598 @@ +import type { DocPage } from '../../types' + +export const docsLink = (slug: string): string => `/docs/${slug}` + +export const docsTextLink = (label: string, slug: string): string => `[${label}](${docsLink(slug)})` + +export const caseLink = (name: string): string => `/cases/${name}` + +export const workerOnlyRecipeFiles = [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +})` + }, + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { locals, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId)` + }, + { + path: 'src/routes/notes/[id].ts', + language: 'ts', + code: String.raw`import { getFetchEvent, locals } from 'devflare/runtime' + +export async function GET(): Promise { + const event = getFetchEvent() + const id = event.params.id + + return Response.json({ + id, + requestId: locals.requestId + }) +}` + }, + { + path: 'tests/worker.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('route tree responds through the worker', async () => { + const response = await cf.worker.get('/api/notes/first') + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ id: 'first' }) +})` + } +] + +export const storageRecipeFiles = [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'storage-api', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + }, + bindings: { + kv: { + CACHE: 'notes-cache' + }, + d1: { + DB: 'notes-db' + }, + r2: { + FILES: 'notes-files' + } + } +})` + }, + { + path: 'src/routes/files/[key].ts', + language: 'ts', + code: String.raw`import { env, getFetchEvent } from 'devflare/runtime' + +export async function PUT(): Promise { + const event = getFetchEvent() + const key = event.params.key + const body = await event.request.text() + + await env.FILES.put(key, body) + await env.CACHE.put('file:' + key, 'present') + + return new Response(null, { status: 204 }) +} + +export async function GET(): Promise { + const key = getFetchEvent().params.key + const object = await env.FILES.get(key) + + return object ? new Response(await object.text()) : new Response('missing', { status: 404 }) +}` + }, + { + path: 'tests/storage.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('file route writes R2 and cache metadata', async () => { + await cf.worker.fetch('/files/readme.txt', { method: 'PUT', body: 'hello' }) + + expect(await env.CACHE.get('file:readme.txt')).toBe('present') + expect(await (await cf.worker.get('/files/readme.txt')).text()).toBe('hello') +})` + } +] + +export const durableObjectRecipeFiles = [ + { + path: 'src/do/counter.ts', + language: 'ts', + code: String.raw`import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async increment(): Promise { + const next = Number((await this.ctx.storage.get('count')) ?? 0) + 1 + await this.ctx.storage.put('count', next) + return next + } +}` + }, + { + path: 'src/routes/counter.ts', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function POST(): Promise { + const counter = env.COUNTER.getByName('global') + return Response.json({ count: await counter.increment() }) +}` + }, + { + path: 'tests/counter.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('counter route uses the real object binding', async () => { + const response = await cf.worker.fetch('/counter', { method: 'POST' }) + + expect(await response.json()).toEqual({ count: 1 }) +})` + } +] + +export const queueRecipeFiles = [ + { + path: 'src/queue.ts', + language: 'ts', + code: String.raw`import type { QueueEvent } from 'devflare/runtime' + +export async function queue(event: QueueEvent): Promise { + for (const message of event.messages) { + await event.env.PROCESSED.put(message.id, JSON.stringify(message.body)) + } +}` + }, + { + path: 'src/scheduled.ts', + language: 'ts', + code: String.raw`import type { ScheduledEvent } from 'devflare/runtime' + +export async function scheduled(event: ScheduledEvent): Promise { + await event.env.JOBS.send({ id: 'maintenance-' + event.scheduledTime }) +}` + }, + { + path: 'tests/queue.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('queue consumer and scheduled producer are triggerable', async () => { + await cf.queue.trigger([{ id: 'job-1', body: { ok: true } }]) + await cf.scheduled.trigger({ scheduledTime: 1_700_000_000_000 }) + + expect(await env.PROCESSED.get('job-1')).toContain('"ok":true') +})` + } +] + +export const serviceBindingRecipeFiles = [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig, ref } from 'devflare/config' + +const math = ref(() => import('./math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway-worker', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + MATH_SERVICE: math.worker + } + } +})` + }, + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.MATH_SERVICE.add(2, 3) + return Response.json({ result }) +}` + }, + { + path: 'math-service/worker.ts', + language: 'ts', + code: String.raw`export function add(a: number, b: number): number { + return a + b +}` + } +] + +export const offlineRecipeFiles = [ + { + path: 'tests/offline-env.test.ts', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createOfflineEnv, describeOfflineSupport } from 'devflare/test' +import config from '../devflare.config' + +test('offline env is enough for pure binding logic', async () => { + const support = describeOfflineSupport('kv') + const env = createOfflineEnv(config, { + kv: { + CACHE: 'CACHE' + } + }) + + await env.CACHE.put('hello', 'offline') + + expect(support.tier).not.toBe('remote-only') + expect(await env.CACHE.get('hello')).toBe('offline') +})` + }, + { + path: 'tests/remote-boundary.test.ts', + language: 'ts', + code: String.raw`import { expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +test.skipIf(await shouldSkip.ai)('AI uses the real remote boundary', async () => { + await createTestContext() + try { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply OK' }] + }) + + expect(result).toBeDefined() + } finally { + await env.dispose() + } +})` + }, + { + path: 'tests/container.test.ts', + language: 'ts', + code: String.raw`import { afterAll, expect, test } from 'bun:test' +import { containers, shouldSkip, stopActiveContainers } from 'devflare/test' + +afterAll(() => stopActiveContainers()) + +const skipContainers = await shouldSkip.containers + +test.skipIf(skipContainers)('container responds without pulling in CI', async () => { + const app = await containers.start('ApiContainer', { + image: 'devflare-fixture:local', + port: 8080, + offline: true + }) + + expect(await app.fetch('/health').then((response) => response.status)).toBe(200) +})` + } +] + +export const svelteKitRecipeFiles = [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kit-worker', + compatibilityDate: '2026-03-17', + framework: { + type: 'sveltekit' + }, + bindings: { + kv: { + CACHE: 'kit-cache' + } + } +})` + }, + { + path: 'src/routes/+page.server.ts', + language: 'ts', + code: String.raw`import type { PageServerLoad } from '../$types' + +export const load: PageServerLoad = async ({ platform }) => { + const message = await platform?.env.CACHE.get('home-message') + return { message: message ?? 'Hello from SvelteKit' } +}` + }, + { + path: 'tests/page.test.ts', + language: 'ts', + code: String.raw`import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('SvelteKit worker receives the Devflare platform env', async () => { + await env.CACHE.put('home-message', 'from-kv') + const response = await cf.worker.get('/') + + expect(response.status).toBe(200) +})` + } +] + +export const previewRecipeFiles = [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + name: 'previewable-worker', + bindings: { + kv: { + CACHE: pv('previewable-cache') + } + } +})` + }, + { + path: '.github/workflows/preview.yml', + language: 'yaml', + code: String.raw`name: Preview + +on: + pull_request: + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/devflare-setup-workspace + - uses: ./.github/actions/devflare-deploy + with: + working-directory: packages/app + target: preview + preview-scope: pr-${'${{ github.event.pull_request.number }}'} + - uses: ./.github/actions/devflare-github-feedback + with: + preview-scope: pr-${'${{ github.event.pull_request.number }}'}` + } +] + +export const recipeRows = [ + [ + 'Worker-only API', + 'Route tree, middleware, env vars, and request tests', + '`first-route-tree`, `http-routing`, case1, case8' + ], + [ + 'KV + D1 + R2', + 'Cache, query data, and file delivery through one Worker boundary', + '`bindings/kv`, `bindings/d1`, `bindings/r2`' + ], + [ + 'Durable Object state', + 'Counter or room-style identity state with route and test', + 'case3, case19' + ], + ['Queue + scheduled job', 'Producer, consumer, retry or maintenance job', 'case6'], + ['Service bindings', '`ref()` plus default and named worker entrypoints', 'case5'], + ['SvelteKit', 'Devflare platform glue and deployment commands', 'case18'], + ['Offline-first tests', '`createOfflineEnv()` and pure mocks', 'offline support matrix'], + ['Remote-boundary tests', '`shouldSkip.*` plus explicit Cloudflare auth lanes', 'case15'], + [ + 'Containers', + 'Docker/Podman-gated local test with `offline: true` when offline', + 'container helper tests' + ], + [ + 'Preview lifecycle', + '`preview.scope()`, inspection, cleanup, and GitHub feedback', + 'preview docs' + ] +] + +export const featureRows = [ + [ + 'Route tree', + 'Full', + 'No Cloudflare product boundary', + '`cf.worker`', + 'N/A', + docsLink('first-route-tree') + ], + [ + 'KV', + 'Full', + 'Account limits and deployed namespace state', + '`createTestContext`, `createOfflineEnv`, `createMockKV`', + 'Managed when scoped', + docsLink('bindings/kv') + ], + [ + 'D1', + 'Full', + 'Account limits and deployed database state', + '`createTestContext`, `createOfflineEnv`, `createMockD1`', + 'Managed when scoped', + docsLink('bindings/d1') + ], + [ + 'R2', + 'Full', + 'Public delivery topology is Cloudflare-owned', + '`createTestContext`, `createOfflineEnv`, `createMockR2`', + 'Managed when scoped', + docsLink('bindings/r2') + ], + [ + 'Durable Objects', + 'Full', + 'Migrations and placement are Cloudflare-owned', + '`createTestContext`', + 'Branch-scoped isolation when needed', + docsLink('bindings/durable-objects') + ], + [ + 'Queues', + 'Full', + 'Delivery and retry semantics are Cloudflare-owned', + '`cf.queue`, `createMockQueue`', + 'Managed when scoped', + docsLink('bindings/queues') + ], + [ + 'Scheduled', + 'Full', + 'Cron scheduling is Cloudflare-owned', + '`cf.scheduled`', + 'Config-owned', + docsLink('create-test-context') + ], + [ + 'Email', + 'Full', + 'Email Routing ingress remains Cloudflare-owned', + '`cf.email`, send-email binding tests', + 'Address rules compile as authored', + docsLink('bindings/send-email') + ], + [ + 'Tail Workers', + 'Full', + 'Live tail routing is Cloudflare-owned', + '`cf.tail`', + 'Handler code only', + docsLink('create-test-context') + ], + [ + 'Workers AI', + 'Remote', + 'Requires Cloudflare account', + '`shouldSkip.ai`', + 'Product-owned', + docsLink('bindings/ai') + ], + [ + 'Vectorize', + 'Remote', + 'Requires Cloudflare account', + '`shouldSkip.vectorize`', + 'Managed when scoped', + docsLink('bindings/vectorize') + ], + [ + 'Hyperdrive', + 'Full', + 'Hosted pooling, placement, credentials, and production routing are Cloudflare-owned', + '`createTestContext`, `createOfflineEnv`', + 'Reuse or resolve when scoped', + docsLink('bindings/hyperdrive') + ], + [ + 'Browser Rendering', + 'Full', + 'Hosted browser service fidelity is Cloudflare-owned', + '`createTestContext` or focused mocks', + 'No account resource cleanup', + docsLink('bindings/browser-rendering') + ], + [ + 'Worker Loaders', + 'Full', + 'Dynamic Worker upload and hosted lifecycle are Cloudflare-owned', + '`createTestContext`, `createMockWorkerLoader`', + 'Config-owned', + docsLink('bindings/worker-loaders') + ], + [ + 'Secrets Store', + 'Full', + 'Account secret provisioning and sync are Cloudflare-owned', + '`createOfflineEnv`, `createMockSecretsStoreSecret`', + 'Product-owned', + docsLink('bindings/secrets-store') + ], + [ + 'Workflows', + 'Full', + 'Deployed durability, retries, scheduling, and instance history are Cloudflare-owned', + '`createTestContext`, `createMockWorkflow`', + 'Product-owned', + docsLink('bindings/workflows') + ], + [ + 'Images', + 'Full', + 'Hosted storage, variants, delivery rules, billing, and final transform fidelity are Cloudflare-owned', + '`createTestContext`, `createMockImagesBinding`', + 'Product-owned', + docsLink('bindings/images') + ], + [ + 'Media Transformations', + 'Full', + 'Real codecs, output fidelity, cache behavior, and billing are Cloudflare-owned', + '`createTestContext`, `createMockMediaBinding`', + 'Product-owned', + docsLink('bindings/media-transformations') + ], + [ + 'Containers', + 'Full', + 'Cloudflare Containers deployment is remote', + '`containers`, `shouldSkip.containers`', + 'Product-owned', + docsLink('bindings/containers') + ] +] diff --git a/apps/documentation/src/lib/docs/content/frameworks.ts b/apps/documentation/src/lib/docs/content/frameworks.ts new file mode 100644 index 0000000..94ec020 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/frameworks.ts @@ -0,0 +1,448 @@ +import type { DocPage } from '../types' + +export const frameworkDocs: DocPage[] = [ + { + slug: 'svelte-with-rolldown', + group: 'Devflare', + navTitle: 'Svelte in workers', + readTime: '5 min read', + eyebrow: 'Frameworks', + title: + 'Render Svelte inside worker bundles by putting the compiler in Rolldown, not the app shell', + summary: + 'When a worker-only fetch surface or Durable Object imports `.svelte`, add the Svelte compiler to `rolldown.options.plugins`. That compilation belongs to Devflareโ€™s worker bundler, not the main Vite plugin chain.', + description: + 'This is the right path when the worker itself renders or consumes Svelte components. Keep the package in worker-only mode if that is all you need, then extend Devflareโ€™s Rolldown pipeline with the Svelte plugins that make those imports compile cleanly.', + highlights: [ + 'Worker-side `.svelte` imports belong to `rolldown.options.plugins`, not to the main Vite plugin chain.', + 'Use `emitCss: false` so the worker bundle stays single-file instead of expecting a browser asset pipeline.', + 'Use SSR-style compilation because the worker is rendering markup or consuming compiled component output.', + 'The same plugin path applies to main worker bundles and Durable Object bundles when those modules import Svelte components.' + ], + facts: [ + { + label: 'Best for', + value: 'Worker-only fetch surfaces or Durable Objects that import `.svelte`' + }, + { label: 'Key extension point', value: '`rolldown.options.plugins`' }, + { + label: 'Rendering shape', + value: 'SSR-style component compilation inside the worker bundle' + } + ], + sourcePages: [ + 'packages/devflare/src/dev-server/server.ts', + 'packages/devflare/src/config/schema.ts', + 'README.md' + ], + sections: [ + { + id: 'choose-this-path', + title: 'Use this path when the worker imports the component', + paragraphs: [ + 'If your worker entry, route module, queue consumer, scheduled handler, or Durable Object imports a `.svelte` file directly, Devflare treats that as a worker bundling concern. The correct place to teach the build how to compile it is the Rolldown pipeline that Devflare owns for worker bundles.', + 'That means you do not need to promote the whole package into a Vite app just because one worker module wants Svelte-based rendering. Worker-only mode remains the intended default until the package truly needs an outer app host.' + ], + callouts: [ + { + tone: 'info', + title: 'Keep the ownership line clean', + body: [ + 'Vite owns the outer app shell when one exists. Rolldown owns the worker code that Devflare bundles itself. Worker-rendered Svelte belongs to the second bucket.' + ] + } + ] + }, + { + id: 'wire-the-plugins', + title: 'Add Svelte to Rolldown options', + snippets: [ + { + title: 'Install the worker-side Svelte toolchain', + language: 'bash', + code: String.raw`bun add -d svelte rollup-plugin-svelte @rollup/plugin-node-resolve` + }, + { + title: 'Configure Svelte in `rolldown.options.plugins`', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' +import resolve from '@rollup/plugin-node-resolve' +import type { Plugin as RolldownPlugin } from 'rolldown' +import svelte from 'rollup-plugin-svelte' + +export default defineConfig({ + name: 'chat-worker', + files: { + fetch: 'src/fetch.ts' + }, + rolldown: { + sourcemap: true, + options: { + plugins: [ + svelte({ + emitCss: false, + compilerOptions: { + generate: 'ssr' + } + }) as unknown as RolldownPlugin, + resolve({ + browser: true, + exportConditions: ['svelte'], + extensions: ['.svelte'] + }) as unknown as RolldownPlugin + ] + } + } +})` + } + ], + bullets: [ + '`emitCss: false` keeps the worker bundle single-file instead of emitting a CSS asset pipeline the worker cannot naturally serve by itself.', + '`generate: `ssr`` fits worker-side rendering better than a browser DOM target.', + '`@rollup/plugin-node-resolve` helps `.svelte` files and `exports.svelte` packages resolve cleanly.' + ] + }, + { + id: 'render-response', + title: 'Render from the worker like any other module import', + snippets: [ + { + title: '`src/Greeting.svelte`', + filename: 'src/Greeting.svelte', + language: 'svelte', + code: String.raw` + +

Hello {name} from Svelte

` + }, + { + title: '`src/fetch.ts`', + language: 'ts', + code: String.raw`import Greeting from './Greeting.svelte' + +export async function fetch(): Promise { + return new Response(Greeting.render({ name: 'Devflare' }).html, { + headers: { + 'content-type': 'text/html; charset=utf-8' + } + }) +}` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Do not over-generalize the plugin stack', + body: [ + 'If a plugin depends on Rollup-only hooks that Rolldown does not support yet, keep that plugin in the main Vite build instead of the worker bundler.' + ] + } + ] + } + ] + }, + { + slug: 'vite-standalone', + group: 'Devflare', + navTitle: 'Vite standalone', + readTime: '5 min read', + eyebrow: 'Frameworks', + title: + 'Use Devflare with a standalone Vite app when Vite is the outer host and Devflare owns Worker config underneath', + summary: + 'An effective Vite config is what opts the package into Vite-backed flows: a local `vite.config.*`, a non-empty `config.vite`, or both together. Use `devflare/vite` when the package really is a Vite app and you want Devflare to keep Worker config, Durable Objects, and generated Wrangler output aligned underneath it.', + description: + 'This is the lane for frontend-first packages that already have a real Vite app shell. Vite keeps HMR and the app build. Devflare plugs generated Worker config, Durable Object discovery, bridge behavior, and Worker-aware artifacts into that pipeline.', + highlights: [ + 'A local `vite.config.*` or non-empty `config.vite` is what opts the package into Vite-backed mode.', + 'The same `devflare dev`, `build`, `types`, and explicit `deploy` loop still applies; Vite changes the host, not the command vocabulary.', + '`devflarePlugin()` generates `.devflare/wrangler.jsonc`, watches config changes, and wires in Worker-specific behavior.', + '`getDevflareConfigs()` is the high-signal helper when you want explicit `@cloudflare/vite-plugin` wiring.', + 'If the package is really just a worker, stay worker-only instead of adding a Vite host that is not doing app-level work.' + ], + facts: [ + { + label: 'Best for', + value: 'Standalone Vite apps that still ship Worker-aware runtime pieces' + }, + { label: 'Mode switch', value: 'Local `vite.config.*` or non-empty `config.vite`' }, + { label: 'Primary helper', value: '`devflare/vite`' } + ], + sourcePages: [ + 'packages/devflare/src/dev-server/server.ts', + 'README.md', + 'packages/devflare/src/config/schema.ts' + ], + sections: [ + { + id: 'opt-into-vite', + title: 'Know what actually enables Vite-backed mode', + bullets: [ + 'A local `vite.config.*` opts the current package into Vite-backed flows.', + 'A non-empty `config.vite` also opts the package into Vite-backed flows.', + 'Vite dependencies by themselves do not switch the package out of worker-only mode.', + 'Without an effective Vite config, `dev`, `build`, and `deploy` stay worker-only.' + ], + callouts: [ + { + tone: 'success', + title: 'Worker-only is still the default', + body: [ + 'Use Vite because the package has a real Vite host, not because it feels like every modern project should have one glued on top.' + ] + } + ] + }, + { + id: 'minimum-wiring', + title: 'Choose the lightest wiring that fits the app', + snippets: [ + { + title: 'Minimal Devflare-side Vite integration', + language: 'ts', + code: String.raw`import { defineConfig } from 'vite' +import { devflarePlugin } from 'devflare/vite' + +export default defineConfig({ + plugins: [devflarePlugin()] +})` + }, + { + title: 'Explicit Cloudflare plugin wiring', + language: 'ts', + code: String.raw`import { defineConfig } from 'vite' +import { cloudflare } from '@cloudflare/vite-plugin' +import { devflarePlugin, getDevflareConfigs } from 'devflare/vite' + +export default defineConfig(async () => { + const { cloudflareConfig, auxiliaryWorkers } = await getDevflareConfigs() + + return { + plugins: [ + devflarePlugin(), + cloudflare({ + config: cloudflareConfig, + auxiliaryWorkers: auxiliaryWorkers.length > 0 ? auxiliaryWorkers : undefined + }) + ] + } +})` + } + ], + paragraphs: [ + 'Use the minimal plugin shape when this file only needs to add Devflareโ€™s Worker-aware behavior and the rest of the Cloudflare Vite wiring already lives elsewhere. Reach for `getDevflareConfigs()` when this file should own the Cloudflare plugin configuration explicitly too.' + ] + }, + { + id: 'plugin-options', + title: '`devflarePlugin()` options', + table: { + headers: ['Option', 'Type', 'Default', 'Description'], + rows: [ + [ + '`configPath`', + '`string`', + '`devflare.config.ts`', + 'Path to the Devflare config file.' + ], + ['`environment`', '`string`', 'โ€”', 'Named environment from config to resolve.'], + ['`doTransforms`', '`boolean`', '`true`', 'Enable Durable Object code transforms.'], + [ + '`watchConfig`', + '`boolean`', + '`true`', + 'Watch the config file for changes in dev mode.' + ], + [ + '`bridgePort`', + '`number`', + '`DEVFLARE_BRIDGE_PORT`', + 'Miniflare bridge port for WebSocket proxying.' + ], + [ + '`wsProxyPatterns`', + '`string[]`', + '`[]`', + 'Additional patterns to proxy WebSocket requests to Miniflare. Patterns from `wsRoutes` in config are included automatically.' + ] + ] + } + }, + { + id: 'what-changes-when-vite-is-active', + title: 'Know what changes once Vite is actually active', + paragraphs: [ + 'The package still uses the same Devflare command loop. What changes is the outer host: Vite takes over the app shell while Devflare keeps resolving worker config, generated Wrangler output, Durable Object discovery, and composed worker entrypoints underneath it.', + 'That means you should think in terms of host ownership, not a separate CLI mode. Reach for this page when the package genuinely became a Vite app, not when you just need one more bundler-shaped knob.' + ], + steps: [ + 'Devflare loads and validates `devflare.config.*` first.', + 'If a local `vite.config.*` exists, Devflare loads it and overlays `config.vite` on top; otherwise it can synthesize `.devflare/vite.config.mjs` from `config.vite` alone. That merged result is the effective Vite config.', + 'Devflare still compiles worker-aware config into generated Wrangler output and may generate `.devflare/worker-entrypoints/main.ts` when worker surfaces need wrapper glue or composition.', + "Build and deploy use the current package's installed Vite so the outer app build and the inner worker plumbing stay aligned." + ], + callouts: [ + { + tone: 'info', + title: 'Same commands, different host', + body: [ + 'You do not learn a second CLI vocabulary for Vite-backed packages. The config decides who hosts the outer app, while the Devflare commands stay familiar.' + ] + } + ] + }, + { + id: 'config-ownership', + title: 'Keep ownership lines obvious', + cards: [ + { + title: 'Vite owns', + body: 'The outer app dev server, HMR, and the app build for packages that are truly Vite apps.' + }, + { + title: 'Devflare owns', + body: 'Generated Wrangler config, composed worker entrypoints, Durable Object discovery, bridge behavior, and worker-aware build glue.' + }, + { + title: 'Generated output', + body: 'Treat `.devflare/vite.config.mjs` and `.devflare/wrangler.jsonc` as output, not as the source of truth you maintain by hand.' + } + ], + bullets: [ + 'If both `vite.config.*` and `config.vite` exist, Devflare merges `vite.config.*` first and then overlays `config.vite`.', + '`wrangler.passthrough.main` is the explicit opt-out if you want to own the Worker main entry completely.' + ] + } + ] + }, + { + slug: 'sveltekit-with-devflare', + group: 'Devflare', + navTitle: 'SvelteKit', + readTime: '5 min read', + eyebrow: 'Frameworks', + title: + 'Compose Devflare with SvelteKit by letting SvelteKit host the app and Devflare supply the Worker platform', + summary: + "Hand SvelteKit's Cloudflare adapter output to Devflare via `wrangler.passthrough.main` (the adapter worker is a build artifact and does not exist until `vite build` runs), keep `sveltekit()` in `vite.config.ts`, and compose `devflare/sveltekit` into `src/hooks.server.ts` so local platform bindings line up with the Worker runtime Devflare manages.", + description: + 'This is the path for full SvelteKit apps where the framework owns the outer shell and Devflare keeps the Worker-facing platform story coherent. It matches the repositoryโ€™s real documentation app and the SvelteKit integration example in the public docs.', + highlights: [ + 'Use `wrangler.passthrough.main` (not `files.fetch`) to point at the adapter\'s `_worker.js`. The adapter writes that file during `vite build`, after Devflare has already resolved its handler paths โ€” so `files.fetch` would fail with "Configured fetch handler โ€ฆ was not found" on a clean checkout.', + 'Keep `devflarePlugin()` and `sveltekit()` together in `vite.config.ts` so Vite stays the app host while Devflare wires Worker config underneath it.', + '`handle` from `devflare/sveltekit` is the simplest hook path, and `createHandle()` is the escape hatch when you need custom hints or enable rules.', + 'When composing with other hooks, put the Devflare handle first so `event.platform` is ready before downstream middleware reads it.' + ], + facts: [ + { label: 'Best for', value: 'Full SvelteKit apps that deploy through Devflare' }, + { + label: 'Worker entry', + value: + 'The adapter worker output your package actually emits, commonly `.svelte-kit/cloudflare/_worker.js` or a repo-specific path such as `.adapter-cloudflare/_worker.js`, wired via `wrangler.passthrough.main`' + }, + { label: 'Hook helper', value: '`devflare/sveltekit`' } + ], + sourcePages: [ + 'packages/devflare/src/dev-server/server.ts', + 'README.md', + 'apps/documentation/README.md' + ], + sections: [ + { + id: 'required-files', + title: 'Wire the SvelteKit package like a SvelteKit app first', + snippets: [ + { + title: '`devflare.config.ts`', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-app', + files: { + // fetch is supplied by SvelteKit's adapter output below; + // keep this false so devflare does not try to compose around an unbuilt artifact. + fetch: false, + durableObjects: 'src/do/**/*.ts' + }, + wrangler: { + passthrough: { + // SvelteKit's @sveltejs/adapter-cloudflare writes this file during vite build. + main: '.svelte-kit/cloudflare/_worker.js' + } + } +})` + }, + { + title: '`vite.config.ts`', + language: 'ts', + code: String.raw`import { defineConfig } from 'vite' +import { sveltekit } from '@sveltejs/kit/vite' +import { devflarePlugin } from 'devflare/vite' + +export default defineConfig({ + plugins: [devflarePlugin(), sveltekit()] +})` + } + ], + paragraphs: [ + 'SvelteKit still owns the app shell, routing, and framework build. Devflare plugs Worker-aware config, generated Wrangler output, and any Durable Object discovery into that Vite-driven flow.', + 'The adapter worker is a **build artifact** โ€” `@sveltejs/adapter-cloudflare` only writes `.svelte-kit/cloudflare/_worker.js` (or your repo\'s equivalent, like `.adapter-cloudflare/_worker.js`) during `vite build`. Devflare resolves handler paths *before* the framework build runs, so pointing `files.fetch` at that path fails on a clean checkout with `Configured fetch handler "โ€ฆ" was not found`. Use `wrangler.passthrough.main` instead: devflare skips composition entirely for the worker entry, and wrangler picks up the adapter output post-build.', + 'If you also have queue handlers, scheduled handlers, durable objects, or routes, keep those in `files.queue` / `files.scheduled` / `files.durableObjects` / `files.routes` as normal source files โ€” composition still applies to those surfaces.' + ] + }, + { + id: 'compose-the-handle', + title: 'Put the Devflare handle at the front of `hooks.server.ts`', + snippets: [ + { + title: 'Simple composed handle', + language: 'ts', + code: String.raw`import { sequence } from '@sveltejs/kit/hooks' +import { handle as devflareHandle } from 'devflare/sveltekit' + +const authHandle = async ({ event, resolve }) => resolve(event) + +export const handle = sequence(devflareHandle, authHandle)` + }, + { + title: 'Custom handle with explicit binding hints', + language: 'ts', + code: String.raw`import { sequence } from '@sveltejs/kit/hooks' +import { createHandle } from 'devflare/sveltekit' + +const devflareHandle = createHandle({ + hints: { + DB: 'd1', + CACHE: 'kv', + CHAT_ROOM: 'do' + } +}) + +export const handle = sequence(devflareHandle)` + } + ], + callouts: [ + { + tone: 'accent', + title: 'Why the order matters', + body: [ + 'The Devflare handle is the piece that prepares `event.platform` in local dev. Put it first so later middleware sees the same platform shape the app expects.' + ] + } + ] + }, + { + id: 'when-to-customize', + title: 'Reach for `createHandle()` only when the simple handle is not enough', + bullets: [ + 'Use the exported `handle` from `devflare/sveltekit` when auto-loaded binding hints from `devflare.config.ts` are enough.', + 'Use `createHandle()` when you need custom binding hints, a custom bridge URL, or a custom `shouldEnable()` rule.', + 'If your repo already points `wrangler.passthrough.main` at the adapter worker, keep that path authoritative instead of duplicating it in `files.fetch`.', + 'Keep the rest of the app in normal SvelteKit patterns; Devflare is there to supply the Worker platform and config alignment, not to replace SvelteKit itself.' + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/operations.ts b/apps/documentation/src/lib/docs/content/operations.ts new file mode 100644 index 0000000..49ee048 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/operations.ts @@ -0,0 +1,476 @@ +import type { DocPage } from '../types' + +const docsLink = (slug: string): string => `/docs/${slug}` + +export const operationsDocs: DocPage[] = [ + { + slug: 'control-plane-operations', + group: 'Ship & operate', + navTitle: 'Control-plane operations', + readTime: '6 min read', + eyebrow: 'Operations', + title: + 'Use the operator command families for account context, live production changes, renames, token bootstrap, and paid-test gates', + summary: + 'Devflareโ€™s deeper CLI families exist so account selection, live production inspection, Worker renames, token lifecycle, and remote paid-test gates stay documented instead of dissolving into ad-hoc command snippets.', + description: + 'The root CLI page maps these command families, but once you start operating real Cloudflare state, the important questions change. Which account is this command acting on? Is this a read-only production inspection or a dry-run rollback? Does this rename update the local config too? Should remote paid tests be enabled at all? This page keeps those answers in one place.', + highlights: [ + 'Use `login` and `account` first so the account context is visible before a command mutates or inspects anything expensive. Not every command family resolves account lanes in the same order, so pass `--account` when ambiguity would be risky.', + '`productions` reads live Cloudflare state and keeps `rollback` plus `delete` behind explicit dry-run versus `--apply` behavior.', + '`worker rename` can sync the remote Worker name and the matching local config name when Devflare can resolve the config safely, but existing preview URLs may keep the old worker name until fresh previews are uploaded.', + '`tokens`, `account usage|limits`, and `remote` are deliberate safety surfaces: one governs account-owned token bootstrap, one exposes Devflare-managed guardrails, and one gates paid remote tests.' + ], + facts: [ + { + label: 'Best for', + value: + 'Teams operating live accounts, releases, and paid test flows instead of only building locally' + }, + { + label: 'Read-only production view', + value: '`devflare productions` and `devflare productions versions`' + }, + { + label: 'Mutation safety habit', + value: 'Prefer dry runs first, then add `--apply` only when the target is obvious' + }, + { + label: 'Paid-test gate', + value: '`devflare remote status|enable|disable` plus `DEVFLARE_REMOTE` awareness' + } + ], + sourcePages: [ + 'src/cli/help-pages/pages/core.ts', + 'src/cli/help-pages/pages/account.ts', + 'src/cli/help-pages/pages/productions.ts', + 'src/cli/help-pages/pages/misc.ts', + 'src/cloudflare/index.ts', + 'src/cli/command-utils.ts' + ], + sections: [ + { + id: 'account-context', + title: 'Choose account context before you operate on anything important', + paragraphs: [ + 'The safest operational habit in Devflare is to resolve account context first. The CLI can infer an account from several places, but when real inventory, preview cleanup, token management, or production control-plane changes are involved, you should know which lane won.', + 'Not every command family resolves those lanes in the same order. Inventory-oriented commands, `productions` discovery, other config-backed operator commands, and token management each consult a slightly different subset of explicit flags, workspace settings, environment, config, and authenticated-account fallbacks.', + '`login`, `account`, and the global or workspace account selectors exist for this reason. They make the account story explicit before the deeper command families start reading or mutating Cloudflare state.' + ], + snippets: [ + { + title: 'Fail an operator script when the expected account is not active', + description: + 'Use the same account helpers as the CLI when automation needs a hard preflight instead of a human-readable inventory page.', + filename: 'scripts/assert-account.ts', + language: 'ts', + code: String.raw`import { account } from 'devflare/cloudflare' + +const expectedAccountId = process.env.CLOUDFLARE_ACCOUNT_ID + +if (!expectedAccountId) { + throw new Error('Set CLOUDFLARE_ACCOUNT_ID before running operator automation') +} + +const primary = await account.getPrimaryAccount() + +if (primary?.id !== expectedAccountId) { + throw new Error('Expected Cloudflare account ' + expectedAccountId + ', got ' + (primary?.id ?? 'none')) +} + +const workers = await account.workers(expectedAccountId) +console.log('Operating on ' + workers.length + ' workers in ' + expectedAccountId)` + }, + { + title: 'Get the account context visible first', + language: 'bash', + code: String.raw`bunx --bun devflare login +bunx --bun devflare account +bunx --bun devflare account workspace +bunx --bun devflare account workers` + } + ], + table: { + headers: ['Command family', 'How account choice resolves', 'Practical habit'], + rows: [ + [ + '`devflare account ...`', + '`--account` wins, then workspace account selection, `CLOUDFLARE_ACCOUNT_ID`, resolved config `accountId`, and finally the primary authenticated account.', + 'Great for inventory, but still pass `--account` when a read or write must be unmistakable.' + ], + [ + '`devflare productions ...`', + '`--account` wins. Otherwise Devflare may scan local configs for primary workers, stop with an explicit error if that scan finds more than one configured `accountId`, and only then fall back to the narrower production account-resolution path.', + 'In a monorepo or mixed-account tree, pass `--account` instead of asking productions to guess.' + ], + [ + 'Other config-backed families such as `previews` and `worker rename`', + 'Explicit `--account` wins; otherwise Devflare can use resolved config `accountId` or later fall back to effective-account preferences and the authenticated account.', + 'Set `accountId` in package config when that package genuinely belongs to one account.' + ], + [ + '`devflare tokens ...`', + 'Uses `--account` first, then workspace account selection, then the primary account visible to the bootstrap token.', + 'Treat token management as its own lane and make the target account obvious in logs.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'Interactive account selection is a real workflow, not just a convenience extra', + body: [ + '`devflare account global` and `devflare account workspace` exist so repeated operational commands can stay honest without pasting account ids into every invocation.', + 'The workspace preference lives with the workspace metadata, while the global default is cached locally and mirrored best-effort to Devflare-managed Cloudflare state when you are authenticated.', + 'Some command families consult those effective-account preferences directly, while others read a narrower lane first. That difference is why the docs call out the command family instead of pretending there is one universal resolution order.', + '`devflare productions` is the strictest example here: if local config discovery turns up multiple configured account ids, it refuses to guess and asks for `--account`.' + ] + } + ] + }, + { + id: 'usage-and-limits', + title: + 'Treat usage and limits as Devflare-managed guardrails, not Cloudflare billing dashboards', + paragraphs: [ + '`devflare account usage` and `devflare account limits` expose the counters and ceilings Devflare uses for its own safety decisions. They are useful operator data, but they are not a full Cloudflare billing or quota dashboard.', + 'Today that mostly means AI request counts, Vectorize operation counts, and related limits that help Devflare decide when remote or preview-heavy workflows should stay deliberate instead of accidental.' + ], + bullets: [ + 'Use these commands as guardrails for Devflare-managed flows, not as the final source of truth for account billing.', + 'If you need official product usage or invoice-level numbers, keep Cloudflareโ€™s own dashboards and docs in the loop.', + 'Some limits are stored for future enforcement or reporting before every one of them becomes an active hard stop.' + ], + callouts: [ + { + tone: 'info', + title: 'Operationally useful, intentionally narrower than billing', + body: [ + 'These numbers are here to help Devflare behave safely. They should inform operator decisions, but they are not a substitute for Cloudflareโ€™s own product-level accounting.' + ] + } + ] + }, + { + id: 'live-production', + title: 'Inspect and change live production deliberately', + paragraphs: [ + '`devflare productions` is the control-plane surface for live production state. It reads Cloudflare deployment data directly, lists current Workers and stored versions, and only mutates production when you move from the read-only views into `rollback` or `delete`.', + 'That split matters because production inspection and production mutation are not the same job. Keep `versions` nearby when you need context, keep dry runs as the default posture, and add `--apply` only when you are already confident about the target.' + ], + table: { + headers: ['Command', 'What it is for', 'Safety rule'], + rows: [ + [ + '`devflare productions`', + 'Inspect live production Workers and the active deployment shape.', + 'Read-only by default.' + ], + [ + '`devflare productions versions`', + 'Inspect recent stored production versions and see which version is active.', + 'Read-only by default.' + ], + [ + '`devflare productions rollback`', + 'Create a fresh production deployment that points at a previous or specific version.', + 'Dry run unless you add `--apply`.' + ], + [ + '`devflare productions delete`', + 'Delete one live production Worker script.', + 'Dry run unless you add `--apply`, and it does not delete independent account resources automatically.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'Production versions are a focused view, not the entire deployment history', + body: [ + '`devflare productions versions` focuses on the recent non-preview versions that matter operationally, and the latest production deployment can still reference more than one active version when Cloudflare is splitting traffic.' + ] + }, + { + tone: 'warning', + title: 'Production deletion is intentionally narrow', + body: [ + '`devflare productions delete` removes the Worker script only. Review KV, D1, R2, queues, and other account resources separately instead of assuming the control plane will clean them up for you.' + ] + } + ] + }, + { + id: 'rename-and-access', + title: 'Use documented commands for renames, token bootstrap, and pricing context', + cards: [ + { + label: 'Worker', + title: '`worker rename`', + body: 'Renames the remote Worker when needed, updates the matching local config name when it can resolve that config safely, warns about remaining local references, and may leave existing preview URLs showing the old worker name until fresh preview uploads exist.' + }, + { + label: 'Tokens', + title: '`tokens`', + body: 'Creates, rolls, lists, and deletes Devflare-managed account-owned API tokens using a bootstrap token that already has token-management permissions. Created tokens include the selected account and all zones in that account so deploys can manage Worker route and custom-domain state. Cloudflare returns token secrets only once, so the first output matters.' + }, + { + label: 'Pricing', + title: '`ai`', + body: 'Prints the built-in Workers AI pricing snapshot bundled with the current Devflare build. It is a reference command, not a live account-state query, so confirm current rates in Cloudflare docs when the numbers matter.' + } + ], + snippets: [ + { + title: 'Keep these control-plane jobs explicit too', + language: 'bash', + code: String.raw`bunx --bun devflare worker rename docs --to devflare-docs +bunx --bun devflare tokens $BOOTSTRAP --list +bunx --bun devflare tokens $BOOTSTRAP --new preview +bunx --bun devflare ai` + } + ], + bullets: [ + 'Prefer `worker rename` over hand-editing config names and remote Worker names separately.', + 'Keep bootstrap tokens out of transcripts and remember that returned managed-token secrets are a one-time output.', + 'Use the built-in AI pricing command when the question is cost reference, not model invocation.' + ] + }, + { + id: 'remote-mode', + title: 'Gate paid remote test flows explicitly', + paragraphs: [ + 'Remote mode exists so paid Cloudflare features like AI or Vectorize do not get exercised casually by every local or CI run. The command family is deliberately small: inspect current status, enable it for a bounded window, or disable it again.', + 'That keeps the cost story visible. If remote tests are going to hit real infrastructure, the activation should be reviewable in command history or workflow logs instead of quietly implied.' + ], + snippets: [ + { + title: 'Make remote mode a deliberate choice', + language: 'bash', + code: String.raw`bunx --bun devflare remote status +bunx --bun devflare remote enable 30 +bunx --bun devflare remote disable` + } + ], + bullets: [ + 'The default `remote` action is `status`, so the current gate is easy to inspect before you run a paid test suite.', + '`enable` defaults to 30 minutes when you do not pass a valid duration.', + '`DEVFLARE_REMOTE` can keep effective remote mode active even after you run `disable`, so environment context still matters.' + ], + callouts: [ + { + tone: 'warning', + title: 'Remote mode is a cost gate, not a convenience toggle', + body: [ + 'Remote tests hit real Cloudflare services. Use the shortest useful enable window and keep the activation visible in automation when cost or quotas matter.' + ] + } + ] + }, + { + id: 'neighbor-pages', + title: 'Use the neighboring docs when the job becomes preview lifecycle or CI policy', + cards: [ + { + label: 'Ship & operate', + title: 'devflare/cloudflare', + body: 'Open the library API page when a script or tool should use the same auth, inventory, registry, usage, or token helpers that the CLI command families use internally.', + href: docsLink('cloudflare-api') + }, + { + label: 'Ship & operate', + title: 'Preview operations', + body: 'Open the preview lifecycle page when the job is inspection or resource cleanup for preview scopes.', + href: docsLink('preview-operations') + }, + { + label: 'Ship & operate', + title: 'GitHub workflows', + body: 'Open the workflow page when those operator commands need to become reviewable CI jobs with feedback, cleanup, and permissions.', + href: docsLink('github-workflows') + }, + { + label: 'Ship & operate', + title: 'Production deploys', + body: 'Open the production deploy page when the question is the deploy target itself rather than the later control-plane inspection or rollback flow.', + href: docsLink('production-deploys') + } + ] + } + ] + }, + { + slug: 'cloudflare-api', + group: 'Ship & operate', + navTitle: 'devflare/cloudflare', + readTime: '6 min read', + eyebrow: 'Library API', + title: + 'Use `devflare/cloudflare` when scripts should reuse Devflareโ€™s account, registry, and token helpers instead of reimplementing them', + summary: + 'The `devflare/cloudflare` subpath exposes the same account-aware building blocks the CLI uses for auth, resource inventory, usage and limits, preview registry access, preferences, and managed token workflows.', + description: + 'This page is for Node-side scripts and tooling, not Worker runtime code. Reach for it when a release script, operator utility, or migration helper should reuse Devflareโ€™s Cloudflare-side knowledge instead of rebuilding auth, pagination, account selection, or preview-registry calls from scratch.', + highlights: [ + 'Import from `devflare/cloudflare` for Node-side automation, not from the Worker runtime surface.', + 'The main public surface is the exported `account` object, which groups auth, inventory, usage, preview registry, preferences, and token helpers.', + 'Use the library when you need the same control-plane behavior as the CLI inside a script, and use the CLI when a command already exists and human-readable output is the real goal.', + 'Preview registry helpers and registry schemas are public too, so custom tooling can stay aligned with Devflareโ€™s preview metadata contract.' + ], + facts: [ + { label: 'Import path', value: '`devflare/cloudflare`' }, + { + label: 'Primary surface', + value: 'A flat `account` object plus standalone preview-registry helpers and schema exports' + }, + { + label: 'Best for', + value: + 'Release scripts, operator tooling, and Node-side automation that should reuse Devflareโ€™s Cloudflare-side rules' + } + ], + sourcePages: [ + 'src/cloudflare/index.ts', + 'src/cloudflare/preferences.ts', + 'src/cloudflare/preview-registry.ts', + 'src/cloudflare/registry-schema.ts' + ], + sections: [ + { + id: 'when-to-use-it', + title: + 'Use the library when your script needs Devflareโ€™s control-plane knowledge, not just a shell command', + paragraphs: [ + 'Reach for `devflare/cloudflare` when a script should authenticate once, resolve an account deliberately, inspect resources, or talk to the preview registry using the same rules Devflare already ships.', + 'If the job is already well-served by `devflare account`, `devflare previews`, or another CLI command and the main need is a readable operator workflow, the CLI is usually simpler. The library is for composition.' + ], + cards: [ + { + title: 'Good fit', + body: 'A release script, CI helper, or internal ops tool needs account auth, inventory queries, preview registry reads, or token management as reusable functions.' + }, + { + title: 'Usually not the first fit', + body: 'A human just needs to inspect state once. That is what the CLI pages and built-in help are already for.' + } + ] + }, + { + id: 'what-it-exports', + title: 'Know the main clusters on the public surface', + table: { + headers: ['Cluster', 'What it helps with', 'Examples'], + rows: [ + [ + 'Auth and account identity', + 'Check auth, inspect accounts, and resolve the account you should operate on.', + '`account.isAuthenticated()`, `account.getAccounts()`, `account.getPrimaryAccount()`' + ], + [ + 'Resource inventory', + 'List Workers, D1 databases, KV namespaces, R2 buckets, Vectorize indexes, and related account resources.', + '`account.workers(accountId)`, `account.d1(accountId)`, `account.r2(accountId)`' + ], + [ + 'Usage and limits', + 'Read Devflare-managed operational counters and ceilings that inform remote or preview-heavy workflows.', + '`account.getUsageSummary(accountId, "ai")`, `account.getLimits(accountId)`' + ], + [ + 'Preferences and defaults', + 'Read or update Devflareโ€™s stored global or workspace account preferences.', + '`account.getGlobalDefaultAccountId(primaryId)`, `account.setWorkspaceAccountId(accountId)`, `account.getEffectiveAccountId(primaryId)`' + ], + [ + 'Managed tokens and preview registry', + 'Create or rotate Devflare-managed API tokens, and inspect or update preview-registry records with shared schemas.', + '`account.listAccountOwnedAPITokens(accountId)`, `account.ensurePreviewRegistry({ ... })`, `devflarePreviewRecordSchema`' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'This is the same mental model as the CLI, just as functions', + body: [ + 'If a CLI page talks about account preferences, preview registry records, or managed tokens, this subpath is usually where the reusable implementation lives.' + ] + } + ] + }, + { + id: 'example-script', + title: 'A small script can reuse auth and inventory without rebuilding them', + snippets: [ + { + title: 'List Workers for the primary account', + filename: 'scripts/list-workers.ts', + language: 'ts', + code: String.raw`import { account } from 'devflare/cloudflare' + +const authenticated = await account.isAuthenticated() + +if (!authenticated) { + throw new Error('Run devflare login before using this script') +} + +const primary = await account.getPrimaryAccount() + + if (!primary) { + throw new Error('No Cloudflare account is available for this script') + } + + const workers = await account.workers(primary.id) + +for (const worker of workers) { + console.log(worker.name) +}` + } + ], + bullets: [ + 'Keep account choice explicit in scripts that can touch more than one account.', + 'Reuse the exported helpers instead of hand-rolling Cloudflare REST calls unless you genuinely need an unsupported endpoint.', + 'Prefer returning structured data from your own scripts and let the CLI own human-readable operator output.' + ] + }, + { + id: 'preview-registry-and-schemas', + title: 'Preview registry helpers and schemas are public by design', + paragraphs: [ + 'Devflare exports preview-registry helpers plus the shared registry schemas and errors so custom tooling can inspect or update preview metadata without guessing the record shape.', + 'That is especially useful for automation that wants to inspect preview URLs, scope metadata, or cleanup state while staying aligned with the same contract the CLI and GitHub actions use.' + ], + bullets: [ + 'Use schema exports such as `devflarePreviewRecordSchema` when you need to validate preview-registry data in your own tooling.', + 'Use `account.ensurePreviewRegistry(...)`, `account.listTrackedPreviewRecords(...)`, or the standalone preview-registry exports when you want the same storage contract the CLI already understands.', + 'Keep custom preview automation aligned with the docs on preview lifecycle instead of inventing parallel record shapes.' + ] + }, + { + id: 'where-to-go-next', + title: + 'Open the neighboring page when the question is policy or workflow, not raw API reuse', + cards: [ + { + label: 'Ship & operate', + title: 'Control-plane operations', + body: 'Go back to the CLI-oriented page when the question is operator workflow, dry-run safety, rollback posture, or command-family behavior.', + href: docsLink('control-plane-operations') + }, + { + label: 'Ship & operate', + title: 'Preview operations', + body: 'Open the preview lifecycle page when your tool needs the broader policy around preview inspection and cleanup flows.', + href: docsLink('preview-operations') + }, + { + label: 'Ship & operate', + title: 'GitHub workflows', + body: 'Open the workflow page when your automation question is really about CI structure, action outputs, or PR feedback instead of raw Cloudflare helpers.', + href: docsLink('github-workflows') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/ship-operate.ts b/apps/documentation/src/lib/docs/content/ship-operate.ts new file mode 100644 index 0000000..f0b172c --- /dev/null +++ b/apps/documentation/src/lib/docs/content/ship-operate.ts @@ -0,0 +1 @@ +export { shipOperateDocs } from './ship-operate/index' diff --git a/apps/documentation/src/lib/docs/content/ship-operate/index.ts b/apps/documentation/src/lib/docs/content/ship-operate/index.ts new file mode 100644 index 0000000..e018848 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/ship-operate/index.ts @@ -0,0 +1,4 @@ +import { shipOperateDocsPart1 } from './part-1' +import { shipOperateDocsPart2 } from './part-2' + +export const shipOperateDocs = [shipOperateDocsPart1, shipOperateDocsPart2].flat() diff --git a/apps/documentation/src/lib/docs/content/ship-operate/part-1.ts b/apps/documentation/src/lib/docs/content/ship-operate/part-1.ts new file mode 100644 index 0000000..55b5381 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/ship-operate/part-1.ts @@ -0,0 +1,683 @@ +import type { DocPage } from '../../types' +import { + deployImpactActionCode, + docsLink, + githubFeedbackCommentCode, + previewDeployActionCode, + productionDeployActionCode, + setupWorkspaceActionCode, + thinPreviewDeployStepCode, + workflowActionRef, + workflowActionRepo, + workflowActionSourceBase, + workflowActionSourceLink, + workflowActionUse, + workflowLink, + workflowRepoBase, + workflowScriptBase, + workflowScriptLink +} from './shared' + +export const shipOperateDocsPart1: DocPage[] = [ + { + slug: 'github-workflows', + group: 'Ship & operate', + navTitle: 'GitHub workflows', + readTime: '7 min read', + eyebrow: 'CI/CD', + title: 'Official GitHub Actions patterns for Devflare', + summary: + 'Devflare ships reusable GitHub Actions for setup, impact checks, deploy execution, and feedback, plus supported workflow strategies for validation, previews, production, and cleanup.', + description: + 'Treat GitHub workflows as policy and target selection. Treat the reusable Devflare actions as the supported mechanics for workspace setup, impact checks, explicit deploys, and GitHub feedback.', + highlights: [ + '`devflare-setup-workspace` prepares Bun and dependencies once per job.', + '`devflare-deploy-impact` gates each deploy target before Cloudflare work begins.', + '`devflare-deploy` owns explicit production or named preview-scope deploys.', + '`devflare-github-feedback` publishes PR comments and deployment records independently from deploy execution.' + ], + facts: [ + { + label: 'Best for', + value: 'GitHub Actions with validation, preview, production, and cleanup lanes' + }, + { label: 'Supported actions', value: '4 reusable actions' }, + { + label: 'Package selector', + value: '`working-directory` picks which Devflare config deploys' + } + ], + sourcePages: [ + workflowLink('workspace-ci.yml'), + workflowLink('preview.yml'), + workflowLink('documentation-production.yml'), + workflowActionSourceLink('devflare-deploy-impact'), + workflowActionSourceLink('devflare-setup-workspace'), + workflowActionSourceLink('devflare-deploy'), + workflowActionSourceLink('devflare-github-feedback'), + workflowScriptLink('verify-testing-preview-deployment.ts') + ], + sections: [ + { + id: 'official-support', + title: 'GitHub Actions are a supported deployment surface', + paragraphs: [ + 'This page is the reference for running Devflare from GitHub Actions. The reusable actions and workflow shapes in this repository are the supported CI/CD patterns, not incidental excerpts copied out of one lucky workflow.', + 'Keep the ownership split sharp: workflows decide when a lane runs, which permissions it gets, which package it targets, and what verification happens afterwards. The reusable Devflare actions own the mechanics that should stay consistent across repositories.' + ], + table: { + headers: ['Layer', 'Owns', 'Should not own'], + rows: [ + [ + 'Workflow file', + 'Triggers, permissions, concurrency, package selection, and verification order.', + 'Deploy argument construction, Bun setup, or PR comment formatting.' + ], + [ + '`devflare-setup-workspace`', + 'Bun installation, cache restore, and one shared workspace install.', + 'Target selection or any deploy step.' + ], + [ + '`devflare-deploy-impact`', + 'Change detection for one deployment target.', + 'Cloudflare deploys or GitHub reporting.' + ], + [ + '`devflare-deploy`', + 'One explicit production or named preview-scope deploy.', + 'PR comment policy or multi-package orchestration.' + ], + [ + '`devflare-github-feedback`', + 'PR comments, deployment records, and inactive cleanup updates.', + 'Cloudflare deploy execution.' + ] + ] + }, + bullets: [ + 'Inside this repository, use local action paths like `./.github/actions/devflare-deploy`.', + 'From another repository, use `Refzlund/devflare/.github/actions/@next`.', + 'Make the target package visible through `working-directory` instead of hiding package selection in a shell wrapper.', + 'Keep validation, preview, production, and cleanup lanes explicit. They have different verification rules for good reasons.' + ], + callouts: [ + { + tone: 'info', + title: 'The goal', + body: [ + 'Reusable mechanics, explicit policy, and CI logs a human can still trust before coffee.' + ] + } + ] + }, + { + id: 'supported-actions', + title: 'Supported reusable actions', + paragraphs: [ + 'Devflare ships four reusable GitHub Actions for the repeatable parts. Use them directly rather than cloning shell logic into every workflow file.', + 'The action source lives in this repository, but the contract is meant to be reused: workspace setup, impact detection, deploy execution, and GitHub feedback are separate on purpose.' + ], + cards: [ + { + href: workflowActionSourceLink('devflare-setup-workspace'), + label: 'Action', + meta: 'Setup', + title: 'devflare-setup-workspace', + body: 'Install Bun, restore the Bun cache, and run one shared workspace install for the job.' + }, + { + href: workflowActionSourceLink('devflare-deploy-impact'), + label: 'Action', + meta: 'Impact', + title: 'devflare-deploy-impact', + body: 'Decide whether one target package actually needs a deploy before Cloudflare work starts.' + }, + { + href: workflowActionSourceLink('devflare-deploy'), + label: 'Action', + meta: 'Deploy', + title: 'devflare-deploy', + body: 'Run one explicit production or named preview-scope deploy and expose outputs for later verification.' + }, + { + href: workflowActionSourceLink('devflare-github-feedback'), + label: 'Action', + meta: 'Feedback', + title: 'devflare-github-feedback', + body: 'Publish PR comments, GitHub deployments, or both without mixing reporting into deploy execution.' + } + ] + }, + { + id: 'setup-workspace-action', + title: '`devflare-setup-workspace`', + paragraphs: [ + 'Use `devflare-setup-workspace` once near the start of a job when later steps share the same checkout and dependency install. It installs Bun, restores the Bun cache, and runs the workspace install command from the chosen directory.', + 'This action is intentionally target-agnostic. It prepares the workspace; it never decides what to deploy.' + ], + bullets: [ + 'Best fit: one job that deploys more than one package or deploys and then runs follow-up verification.', + 'In a monorepo, keep `working-directory: .` so package deploy steps can reuse the root install.', + "Later `devflare-deploy` steps should set `skip-setup: 'true'` and `skip-install: 'true'` after shared setup already ran.", + 'If you only have one simple deploy step, you can let `devflare-deploy` handle setup itself instead.' + ], + snippets: [ + { + title: 'Prepare the workspace once', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: setupWorkspaceActionCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Use it when the job has shared setup work', + body: [ + 'This action exists so Bun setup and dependency installation stay boring. That is a compliment.' + ] + } + ] + }, + { + id: 'deploy-impact-action', + title: '`devflare-deploy-impact`', + paragraphs: [ + 'Use `devflare-deploy-impact` before any Cloudflare work. It compares the target package against the relevant git range and tells the workflow whether a deploy is actually needed.', + 'Call it once per deployment target. In multi-package preview families, that means one impact decision per worker or app, not one giant yes-or-no for the whole job.' + ], + table: { + headers: ['Key field', 'Why it matters'], + rows: [ + [ + '`target-package`', + 'Selects the workspace package whose changes should trigger a deploy.' + ], + [ + '`extra-paths`', + 'Lets shared files outside the package root invalidate that target too.' + ], + [ + '`should-deploy`', + 'The boolean gate your workflow should use before any deploy step runs.' + ], + ['`reason`', 'Short explanation you can surface in summaries, PR comments, and logs.'], + ['`changed-files`', 'Audit trail for what the comparison actually saw.'] + ] + }, + bullets: [ + 'Run it before deploys, not after โ€” skipping a no-op deploy is the whole point.', + 'Pass event metadata from GitHub instead of guessing at comparison refs in shell.', + 'Keep one impact decision per target so the workflow can skip or deploy packages independently.', + 'Promote the `reason` output into human-readable feedback. It makes skipped runs much easier to trust.' + ], + snippets: [ + { + title: 'Gate the deploy before Cloudflare work starts', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: deployImpactActionCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Use this to skip the boring non-events', + body: [ + 'No-op deploys still cost time, secrets exposure, and reviewer attention. This action exists to spend less of all three.' + ] + } + ] + }, + { + id: 'deploy-action', + title: '`devflare-deploy`', + paragraphs: [ + 'Use `devflare-deploy` for the actual Devflare deploy step. It can prepare Bun and dependencies for a standalone job, or it can reuse shared setup from an earlier `devflare-setup-workspace` step.', + "The action requires one explicit target. Use `production: 'true'` for `--prod`, or `preview-scope: ` for `--preview `. `working-directory` selects which package-local `devflare.config.ts` and scripts are in play.", + 'Its outputs are the hand-off point for the rest of the workflow: `preview-url`, `version-id`, `verification-note`, `status`, `failure-stage`, `exit-code`, and `log-excerpt` are all meant for later verification and GitHub feedback.' + ], + table: { + headers: ['Input or output', 'Role'], + rows: [ + ['`working-directory`', 'Selects the package-local Devflare config and scripts.'], + ['`production`', 'Requests an explicit `--prod` deployment.'], + [ + '`preview-scope`', + 'Requests an explicit named preview deployment via `--preview `.' + ], + [ + '`verify-deployment`', + 'Controls whether the action enforces Cloudflare control-plane verification.' + ], + [ + '`require-fresh-production-deployment`', + 'Tightens production verification when a new live deployment must be visible.' + ], + [ + '`preview-url`, `version-id`, `verification-note`, `status`', + 'Outputs the rest of the workflow should consume for verification and feedback.' + ] + ] + }, + snippets: [ + { + title: 'Named preview deploy', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: previewDeployActionCode + }, + { + title: 'Explicit production deploy', + filename: '.github/workflows/production.yml', + language: 'yaml', + code: productionDeployActionCode + } + ], + bullets: [ + "Production is the supported lane for strict control-plane verification. Leave `verify-deployment` at its default `true`, and enable `require-fresh-production-deployment: 'true'` when you need a hard failure if Cloudflare keeps the old live deployment.", + 'Preview workflows in this repository use named preview scopes and then perform app-level verification after the deploy step. That is the supported preview posture here.', + 'Use `deploy-command` when the package already wraps Devflare behind `bun run deploy --` or another package-local script.', + 'Use `install-working-directory` to reuse a workspace-root install while still deploying from a package subdirectory.', + 'Pass `deploy-message` and `deploy-tag` when you want workflow runs to map cleanly onto Cloudflare version history.' + ], + callouts: [ + { + tone: 'warning', + title: 'Choose exactly one target', + body: [ + 'The action intentionally rejects ambiguous callers. If a workflow cannot tell whether it is preview or production, the logs will not be much comfort later either.' + ] + }, + { + tone: 'info', + title: 'Preview verification is different from production verification', + body: [ + 'Preview jobs still need real post-deploy checks for the application they expose. In this repository that means URL and content verification for documentation previews plus deployed-binding verification for the testing preview family.' + ] + } + ] + }, + { + id: 'github-feedback-action', + title: '`devflare-github-feedback`', + paragraphs: [ + 'Use `devflare-github-feedback` to publish the result after deploy and verification have already been decided. It can update a PR comment, a GitHub deployment record, or both.', + 'Keeping feedback separate from deploy execution matters. You can retry reporting, mark cleanup inactive, or change comment grouping without touching the Cloudflare deploy mechanics.' + ], + table: { + headers: ['Field', 'Use it for'], + rows: [ + ['`mode`', 'Choose PR comments, GitHub deployments, or both.'], + ['`operation`', 'Differentiate normal reporting from cleanup or inactive updates.'], + ['`status`', 'Publish `success`, `failure`, `skipped`, `in_progress`, or `inactive`.'], + [ + '`comment-key` and `comment-section-key`', + 'Keep one durable PR comment and merge multiple preview sections into it.' + ], + [ + '`environment` and `environment-url`', + 'Populate the GitHub Deployments UI with the right environment identity.' + ], + [ + '`log-url` and `log-excerpt`', + 'Make failure context readable without digging through raw workflow output.' + ] + ] + }, + bullets: [ + 'Use `mode: deployment` for branch previews and production lanes that should show up in the GitHub Deployments UI.', + 'Use `mode: comment` for PR previews and group multiple sections into one stable comment with `comment-key` plus `comment-section-key`.', + 'Use `operation: cleanup` and `status: inactive` after preview cleanup so GitHub stops pretending old previews are still alive.', + 'Surface `summary`, `details-markdown`, and log links so reviewers do not have to spelunk raw job output.' + ], + snippets: [ + { + title: 'Publish grouped PR feedback', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: githubFeedbackCommentCode + } + ] + }, + { + id: 'supported-strategies', + title: 'Supported workflow files and deployment strategies', + paragraphs: [ + 'The repository currently demonstrates three workflow files and six supported lane types. You do not need to collapse them into one mega-workflow to be โ€œofficialโ€; the official part is the clear contract between the workflow lane and the reusable actions.' + ], + table: { + headers: ['Strategy', 'Workflow file', 'Verification style', 'GitHub surface'], + rows: [ + [ + 'Validation only', + '`workspace-ci.yml`', + 'Workspace build, typecheck, and test validation.', + 'None โ€” this lane does not deploy.' + ], + [ + 'Branch preview', + '`preview.yml`', + 'Target checks plus app-level verification after deploy.', + 'GitHub deployment record.' + ], + [ + 'Pull request preview', + '`preview.yml`', + 'Target checks plus app-level verification after deploy.', + 'Grouped PR comment.' + ], + [ + 'Multi-package preview family', + '`preview.yml`', + 'Per-package deploys plus family-level verification.', + 'GitHub deployment record and grouped PR comment.' + ], + [ + 'Production', + '`documentation-production.yml`', + 'Deploy action control-plane checks plus live URL verification.', + 'GitHub deployment record.' + ], + [ + 'Cleanup', + '`preview.yml`', + 'Successful cleanup command plus inactive feedback update.', + 'Inactive deployment or PR comment section.' + ] + ] + }, + cards: [ + { + title: 'workspace-ci.yml', + body: 'Validation-only lane for the monorepo. No Cloudflare target, no deploy side door.', + href: workflowLink('workspace-ci.yml') + }, + { + title: 'preview.yml', + body: 'Shared preview lifecycle workflow for branch previews, PR previews, multi-package preview families, and cleanup.', + href: workflowLink('preview.yml') + }, + { + title: 'documentation-production.yml', + body: 'Explicit production lane for the documentation app with live verification after deploy.', + href: workflowLink('documentation-production.yml') + } + ] + }, + { + id: 'validation-strategy', + title: 'Validation strategy: `workspace-ci.yml`', + paragraphs: [ + '`workspace-ci.yml` is the validation lane. It restores Bun and Turborepo caches, installs once, and runs `bun run devflare:ci`.', + 'It intentionally does not choose a Cloudflare target or request Cloudflare secrets. That keeps repo-wide confidence separate from deploy intent.' + ], + bullets: [ + 'Trigger it on repo-wide changes that affect apps, cases, packages, or shared tooling.', + 'Use it to prove the monorepo still builds, types, and tests before package-specific deploy lanes matter.', + 'Treat it as a prerequisite lane, not a back door into deployment.' + ], + callouts: [ + { + tone: 'success', + title: 'Validation stays validation', + body: [ + 'If a workflow validates the workspace, let it do that well. Sneaking deploy behavior into it is how release lanes get mysterious.' + ] + } + ] + }, + { + id: 'branch-preview-strategy', + title: 'Branch preview strategy', + paragraphs: [ + 'Non-default branch pushes get a stable branch-named preview scope in `preview.yml`. The workflow resolves context once, sets up the workspace once, and then updates only the affected targets for that branch scope.', + 'This is the supported pattern when you want a shareable branch preview that survives multiple pushes and can also coexist with a PR-scoped preview.' + ], + bullets: [ + 'The preview scope is the source branch name.', + 'Run `devflare-deploy-impact` before each deploy target so unchanged packages skip Cloudflare work.', + 'Publish a GitHub deployment record for branch previews so reviewers can find the environment history.', + 'Follow the deploy with app-specific verification, not just โ€œthe command exitedโ€.' + ], + cards: [ + { + title: 'preview.yml', + body: 'The shared preview workflow resolves context once and then updates branch-scoped targets separately from PR-scoped targets.', + href: workflowLink('preview.yml') + } + ] + }, + { + id: 'pr-preview-strategy', + title: 'Pull request preview strategy', + paragraphs: [ + 'Pull requests targeting the default branch get a stable `pr-` preview scope in the same `preview.yml` workflow. The workflow can update the branch preview, the PR preview, or both from the same checkout when that branch already belongs to an open PR.', + 'PR preview reporting is grouped into one comment so documentation and testing results update in place instead of spraying the thread with duplicate status noise.' + ], + bullets: [ + 'Use `opened`, `reopened`, and `ready_for_review` to create or refresh the PR preview.', + 'Use `comment-key: pr-deployment-status` plus section keys to merge multiple preview lanes into one durable comment.', + 'If impact says `skip`, report `skipped` and leave the existing preview in place rather than tearing it down.', + 'Keep branch and PR deploy steps separate even when they share preparation work. They are different targets with different review questions.' + ], + callouts: [ + { + tone: 'info', + title: 'Stable PR scopes reduce churn', + body: [ + 'Updating `pr-` in place is much easier to review than minting a brand-new preview identity on every commit.' + ] + } + ] + }, + { + id: 'multi-package-preview-strategy', + title: 'Multi-package preview family strategy', + paragraphs: [ + 'Some applications are really a family of workers. `apps/testing` is the reference pattern: auth service, search service, and main app deploy separately, but they share one preview scope and one workflow lane.', + 'This is the supported strategy when previews need stronger isolation than same-worker uploads can provide, or when bindings across multiple workers must resolve together.' + ], + bullets: [ + 'Evaluate impact per worker or app package.', + 'Deploy each package with its own `working-directory` and the same `preview-scope`.', + 'Add one family-level verification step after the main deploy to confirm the deployed bindings and URLs line up.', + 'Publish both deployment records and grouped PR feedback from the same verified result.' + ], + cards: [ + { + title: 'verify-testing-preview-deployment.ts', + body: 'The testing preview family finishes with a purpose-built verification script that checks the deployed binding shape, not just deploy command exit codes.', + href: workflowScriptLink('verify-testing-preview-deployment.ts') + } + ], + callouts: [ + { + tone: 'warning', + title: 'This is the right instinct for DO-heavy or service-bound apps', + body: [ + 'When one preview really means several workers plus shared bindings, model that explicitly instead of pretending one same-worker upload tells the full truth.' + ] + } + ] + }, + { + id: 'production-strategy', + title: 'Production strategy', + paragraphs: [ + '`documentation-production.yml` is the reference production lane: resolve impact, perform one explicit production deploy, verify the live site, and then publish a GitHub deployment.', + 'This is the supported split for production automation: let the deploy action handle Cloudflare control-plane verification, then add one live check that proves the currently served app really matches the commit you just shipped.' + ], + bullets: [ + 'Run on default-branch pushes or manual dispatch.', + "Use `production: 'true'` instead of inferring production from branch names inside shell logic.", + 'Keep `verify-deployment` enabled for production.', + 'Use the deploy output URL or the stable production URL for a live content check like `/build.json`.', + 'Publish the final environment URL and version ID back to GitHub.' + ], + cards: [ + { + title: 'documentation-production.yml', + body: 'The reference production workflow for a Devflare app: impact check, explicit production deploy, live verification, then GitHub deployment feedback.', + href: workflowLink('documentation-production.yml') + } + ], + callouts: [ + { + tone: 'success', + title: 'Production gets the strictest verification', + body: [ + 'Production should fail when the control plane or the live URL cannot prove what is serving. Better a loud release lane than a confident fiction.' + ] + } + ] + }, + { + id: 'cleanup-strategy', + title: 'Cleanup strategy', + paragraphs: [ + 'Cleanup is a supported lifecycle lane, not an afterthought. `preview.yml` handles branch deletion, PR closure, and manual cleanup dispatches from the same policy surface as preview creation.', + 'Each cleanup job checks out the default branch, reinstalls the shared workspace, runs `devflare previews cleanup --scope --apply`, and then marks the matching GitHub deployment or PR comment section inactive.' + ], + bullets: [ + 'Use branch deletion or manual dispatch for branch-scoped cleanup.', + 'Use PR closure for PR-scoped cleanup.', + 'Keep the scope name identical to the deploy lane so cleanup is obvious and deterministic.', + 'Mark feedback inactive after infrastructure cleanup so GitHub reflects reality instead of wishful thinking.' + ], + callouts: [ + { + tone: 'accent', + title: 'Cleanup is part of the contract', + body: ['A preview strategy that never documents cleanup is just deferred archaeology.'] + } + ] + } + ] + }, + { + slug: 'production-deploys', + group: 'Ship & operate', + navTitle: 'Production deploys', + readTime: '4 min read', + eyebrow: 'Production', + title: 'Explicit production deploys with inspectable output', + summary: + 'Production uses `--prod` or `--production`, preview uses `--preview` or `--preview `. No target means no deploy.', + description: + 'Devflare resolves config, generates Wrangler artifacts, and deploys against an explicit destination.', + highlights: [ + '`devflare build` prepares artifacts without deploying.', + '`devflare deploy` requires an explicit target: `--prod`, `--production`, `--preview`, or `--preview `.', + 'Production deploys clear preview naming overrides so stable worker names stay stable.', + '`config print` and `doctor` are the easiest preflight tools.' + ], + facts: [ + { label: 'Best for', value: 'Production deploys and preflight checks' }, + { + label: 'Required target', + value: '`--prod`, `--production`, `--preview`, or `--preview `' + }, + { label: 'Best debug habit', value: 'Inspect compiled output before deploying' } + ], + sourcePages: ['packages/devflare/src/cli/commands/deploy.ts', 'README.md'], + sections: [ + { + id: 'command-shape', + title: 'The production lane', + paragraphs: [ + 'Refresh generated types when bindings or entrypoints changed, build once, inspect when the setup changed, then deploy with an explicit production target.', + 'The CLI page owns the broad command map. This page covers how those commands fit the release lane.' + ], + steps: [ + 'Run `devflare types` when bindings or entrypoints changed and `env.d.ts` needs to catch up.', + 'Run `devflare build --env production` to generate production artifacts.', + 'Use `devflare config print --format wrangler` or `devflare doctor` when the compiled result needs inspection before release.', + 'Run `devflare deploy --prod` or `--production` only when the target is unmistakably production. Add `--dry-run` first if you want to verify the pipeline without pushing.' + ], + snippets: [ + { + title: 'Production release workflow with an explicit target', + description: + 'Keep the same local release lane visible in CI: generate types, build production output, dry-run the deploy, then push only with `--prod`.', + filename: '.github/workflows/production.yml', + language: 'yaml', + code: String.raw`name: Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx --bun devflare types + - run: bunx --bun devflare build --env production + - run: bunx --bun devflare deploy --prod --dry-run + - run: bunx --bun devflare deploy --prod` + } + ], + callouts: [ + { + tone: 'info', + title: 'Need the full command map?', + body: [ + 'Open the CLI page when the question is what `types`, `build`, `config`, or `doctor` generally do. This page only covers how those commands fit the production release lane.' + ] + } + ] + }, + { + id: 'explicit-production', + title: 'Production deploys are explicit', + paragraphs: [ + 'Deploy requires an explicit target so production and preview stay unmistakable. Production is `--prod` or `--production`; preview is `--preview` or `--preview `.', + 'Production deploys also clear preview-scope overrides like `DEVFLARE_PREVIEW_BRANCH` so stable worker names point at stable infrastructure.' + ], + snippets: [ + { + title: 'Production deploy commands', + language: 'bash', + code: String.raw`bunx --bun devflare build --env production +bunx --bun devflare deploy --prod +bunx --bun devflare deploy --production --message "Release 1" --tag release-1` + } + ], + callouts: [ + { + tone: 'warning', + title: 'No target means no deploy', + body: [ + 'Intentional. Keeps production vs. preview intent visible in CI logs and command history.' + ] + }, + { + tone: 'info', + title: 'Stricter verification in automation', + body: [ + 'The reusable deploy action exposes `verify-deployment` and `require-fresh-production-deployment` so CI can fail when Cloudflare cannot confirm the expected version.' + ] + } + ] + }, + { + id: 'preflight', + title: 'Preflight tools', + bullets: [ + '`devflare deploy --prod --dry-run` โ€” run the full deploy pipeline without pushing anything to Cloudflare.', + '`devflare config print --format wrangler` โ€” see the compiled deployment shape.', + '`devflare doctor` โ€” check config resolution, Vite opt-in, and generated files.', + '`devflare build` before deploy โ€” when the package just gained new bindings, routes, or framework wiring.' + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/ship-operate/part-2.ts b/apps/documentation/src/lib/docs/content/ship-operate/part-2.ts new file mode 100644 index 0000000..14abe02 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/ship-operate/part-2.ts @@ -0,0 +1,617 @@ +import type { DocPage } from '../../types' +import { + deployImpactActionCode, + docsLink, + githubFeedbackCommentCode, + previewDeployActionCode, + productionDeployActionCode, + setupWorkspaceActionCode, + thinPreviewDeployStepCode, + workflowActionRef, + workflowActionRepo, + workflowActionSourceBase, + workflowActionSourceLink, + workflowActionUse, + workflowLink, + workflowRepoBase, + workflowScriptBase, + workflowScriptLink +} from './shared' + +export const shipOperateDocsPart2: DocPage[] = [ + { + slug: 'monorepo-turborepo', + group: 'Ship & operate', + navTitle: 'Monorepos & Turborepo', + readTime: '6 min read', + eyebrow: 'Monorepo', + title: 'Turborepo validates the workspace, Devflare deploys the target package', + summary: + 'Turbo owns task orchestration and caching. `devflare` still runs from the package that owns the Worker or app.', + description: + 'Turbo at the root, `devflare.config.ts` local to each deployable package. Turbo decides what to build; deploy commands run in the package that owns the config.', + highlights: [ + 'Each deployable package keeps its own `devflare.config.ts` and package-level scripts.', + 'Turbo handles cached validation and targeted package work from the repo root.', + 'Deploy from the target package directory or set it as the Actions working directory.', + 'Same monorepo can mix same-worker previews and multi-worker preview families.' + ], + facts: [ + { label: 'Best for', value: 'Bun + Turborepo monorepos with multiple Devflare packages' }, + { label: 'Turbo role', value: 'Validation, caching, filters, orchestration' }, + { label: 'Deploy rule', value: 'Run `devflare` from the package that owns the config' } + ], + sourcePages: [ + 'README.md', + 'packages/devflare/src/cli/commands/deploy.ts', + 'packages/devflare/src/test/simple-context.ts' + ], + sections: [ + { + id: 'workspace-shape', + title: 'Keep the workspace boundary clear', + paragraphs: [ + 'In a monorepo, Turbo and Devflare solve different problems. Turbo owns the workspace graph: cached builds, targeted checks, and โ€œwhat changed?โ€ filters. Devflare owns package-local Cloudflare behavior: config resolution, generated Wrangler output, preview logic, and production deploys.', + 'That means every deployable package should still keep its own `devflare.config.ts`, package scripts, and package-specific runtime assumptions. Turbo should orchestrate those packages, not erase their boundaries.' + ], + bullets: [ + 'Keep one `devflare.config.ts` per deployable package or worker family member.', + 'Use repo-root Turbo scripts for validation lanes and targeted build/check work.', + 'Use package-local `devflare` commands for actual build or deploy intent.', + 'Use GitHub workflow path filters or Turbo filters to decide whether a deploy job should run at all.' + ] + }, + { + id: 'roles', + title: 'Know which layer owns what', + table: { + headers: ['Layer', 'Owns'], + rows: [ + [ + 'Turborepo', + 'Task graph, caching, filters, workspace validation lanes, and targeted build/check/test/type flows.' + ], + [ + 'Devflare', + 'Config resolution, type generation, worker bundling, preview deploys, production deploys, and preview lifecycle commands.' + ], + [ + 'GitHub Actions', + 'Triggers, permissions, branch/PR policy, feedback, and the working directory that selects the target package.' + ] + ] + }, + callouts: [ + { + tone: 'info', + title: 'Good default review question', + body: [ + 'Ask two separate questions: โ€œWhich packages should Turbo run?โ€ and โ€œWhich package is actually deploying?โ€ Conflating those is how monorepo deploy flows get muddy.' + ] + } + ] + }, + { + id: 'root-lanes', + title: 'Repo-root Turbo scripts for contributors and CI', + paragraphs: [ + 'The repo exposes root scripts for the core Devflare workflow so contributors and CI can validate without guessing at filters.', + 'These are validation and orchestration tools, not a replacement for package-local deploy commands.' + ], + snippets: [ + { + title: 'Root scripts keep Turbo orchestration separate from package deploys', + description: + 'Use root scripts for workspace validation and keep each app package responsible for the Devflare command that resolves its own config.', + activeFile: 'package.json', + structure: [ + { path: 'package.json' }, + { path: 'turbo.json' }, + { path: 'apps/documentation/package.json' } + ], + files: [ + { + path: 'package.json', + language: 'json', + code: String.raw`{ + "scripts": { + "devflare:build": "turbo run build --filter=devflare --filter=documentation", + "devflare:test": "turbo run test --filter=...devflare", + "devflare:ci": "bun run devflare:build && bun run devflare:test" + } +}` + }, + { + path: 'turbo.json', + language: 'json', + code: String.raw`{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".svelte-kit/**"] + }, + "test": { + "dependsOn": ["build"] + } + } +}` + }, + { + path: 'apps/documentation/package.json', + language: 'json', + code: String.raw`{ + "scripts": { + "deploy": "devflare deploy", + "deploy:preview": "devflare deploy --preview docs-preview", + "deploy:prod": "devflare deploy --prod" + } +}` + } + ] + }, + { + title: 'Repo-root validation lane', + language: 'bash', + code: String.raw`bun run devflare:build +bun run devflare:typecheck +bun run devflare:test +bun run devflare:types +bun run devflare:check +bun run devflare:ci` + }, + { + title: 'Targeted Turbo work from the repo root', + language: 'bash', + code: String.raw`bun run turbo build --filter=documentation +bun run turbo check --filter=documentation` + } + ] + }, + { + id: 'deploy-one-package', + title: 'Deploy from the package that owns the config', + steps: [ + 'Use Turbo or path-aware workflow logic to decide whether a package is affected.', + 'Optionally run Turbo build/check work for that package from the repo root.', + 'Run `devflare deploy ...` from the package directory that owns the `devflare.config.ts` you actually want to resolve.', + 'Keep preview-vs-production intent explicit in the final package-local deploy command.' + ], + snippets: [ + { + title: 'Documentation app from a monorepo', + language: 'bash', + code: String.raw`# optional repo-root validation +bun run turbo build --filter=documentation +bun run turbo check --filter=documentation + +# actual deploy from the app package +cd apps/documentation +bun run deploy -- --preview feature-search +bun run deploy -- --prod` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Keep package selection explicit', + body: [ + 'If the deploy is for `apps/documentation`, make that obvious in the working directory or script name. The package boundary should be visible in logs and workflow steps.' + ] + } + ] + }, + { + id: 'worker-families', + title: 'Multi-worker previews deploy per-package', + paragraphs: [ + '`apps/testing` shows the other half: Turbo orchestrates the workspace, but a branch-scoped preview family still deploys each worker separately with the same preview scope.', + 'The workflows keep `DEVFLARE_PREVIEW_BRANCH` consistent and run separate deploys for `auth-service`, `search-service`, and the main app.' + ], + snippets: [ + { + title: 'Branch-scoped worker family deployment', + language: 'bash', + code: String.raw`export DEVFLARE_PREVIEW_BRANCH='pr-123' +# PowerShell: $env:DEVFLARE_PREVIEW_BRANCH = 'pr-123' + +cd apps/testing/workers/auth-service +bunx --bun devflare deploy --preview pr-123 + +cd ../search-service +bunx --bun devflare deploy --preview pr-123 + +cd ../../ +bunx --bun devflare deploy --preview pr-123 +bunx --bun devflare previews cleanup --scope pr-123 --apply` + } + ] + } + ] + }, + { + slug: 'preview-strategies', + group: 'Ship & operate', + navTitle: 'Preview strategies', + readTime: '5 min read', + eyebrow: 'Previews', + title: 'Pick the preview model that matches the app', + summary: + 'Same-worker uploads, named preview scopes, and branch-scoped worker families serve different needs.', + description: + 'Pick the right preview model before writing CI around assumptions the platform will not honor.', + highlights: [ + 'Plain `--preview` keeps the same-worker upload flow.', + '`--preview ` uses an explicit scope for resource naming and cleanup.', + 'Use plain `--preview` for same-worker uploads, `--preview ` when the scope should be visible in logs and cleanup.', + 'Preview URLs are public unless protected, and have Cloudflare caveats.', + 'DO-heavy apps often need branch-scoped worker families.' + ], + facts: [ + { label: 'Best for', value: 'Choosing preview strategy before building CI' }, + { label: 'Same-worker mode', value: 'Plain `--preview`' }, + { label: 'Named scope mode', value: '`--preview `' } + ], + sourcePages: ['packages/devflare/src/cli/commands/deploy.ts', 'README.md'], + sections: [ + { + id: 'choose-model', + title: 'More than one preview model', + table: { + headers: ['Preview style', 'Use it when'], + rows: [ + [ + 'Plain `--preview`', + 'You want a same-worker preview upload and the synthetic `preview` identifier is enough for any `preview.scope()` resource names.' + ], + [ + 'Named `--preview `', + 'You need an explicit preview identifier for resource names or branch-scoped preview workers.' + ], + [ + 'Branch-scoped worker family', + 'The app is Durable Object-heavy or otherwise needs stronger isolation than same-worker preview uploads can provide.' + ] + ] + }, + paragraphs: [ + 'Both targets resolve `config.env.preview` and can materialize `preview.scope()` names. Bare `--preview` uses the synthetic `preview` identifier; `--preview ` swaps it for an explicit scope that pairs with branch-scoped preview workers.', + 'Plain `--preview` can still receive `--branch-name` or CI metadata for logs, but preview-scoped resource names use the synthetic identifier unless you pick an explicit scope.', + 'When you need stronger isolation or cleaner cleanup, prefer named scopes directly.' + ] + }, + { + id: 'cloudflare-caveats', + title: 'Cloudflare caveats still matter', + bullets: [ + 'Preview URLs must be enabled for the worker or the returned links may not be usable.', + 'Preview URLs are public unless you protect them with Cloudflare Access or another layer.', + 'Plain `--preview` cannot be the first-ever upload path for a brand-new worker.', + 'Cloudflare does not currently generate preview URLs for workers that implement Durable Objects.', + '`wrangler versions upload` does not currently apply Durable Object migrations.', + 'Same-worker preview uploads are also the wrong fit when branch isolation must cover cron or queue topology, not just the request path.' + ], + callouts: [ + { + tone: 'warning', + title: 'DO-heavy apps need a different preview instinct', + body: [ + 'If previews must exercise real Durable Object behavior, use branch-scoped worker families and preview-scoped resources.' + ] + } + ] + }, + { + id: 'preview-resources', + title: 'Preview-scoped resources', + paragraphs: [ + 'Branch-scoped previews sometimes need their own KV, D1, R2, Queue, or Vectorize resources. `preview.scope()` keeps authored config stable while preview environments resolve preview-specific names.', + 'Outside preview, those markers resolve back to the base names. Inside preview, bare `--preview` materializes names like `my-cache-kv-preview`; `--preview next` materializes `my-cache-kv-next`.' + ], + snippets: [ + { + title: 'Preview-scoped resource naming', + language: 'ts', + code: String.raw`import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + bindings: { + kv: { + CACHE: pv('my-cache-kv') + }, + r2: { + ASSETS: pv('my-assets-bucket') + } + } +})` + } + ] + } + ] + }, + { + slug: 'preview-operations', + group: 'Ship & operate', + navTitle: 'Preview operations', + readTime: '5 min read', + eyebrow: 'Preview lifecycle', + title: 'Inspect and clean up previews', + summary: + 'The preview registry is D1-backed, giving Devflare durable records of scope and deployment state for reliable cleanup.', + description: + 'Preview commands are the public surface for understanding what exists and tearing down preview-only resources.', + highlights: [ + '`previews` gives the family or registry view; `bindings --scope ` inspects one resolved scope.', + 'Deploy flows keep preview metadata synchronized automatically.', + '`cleanup` removes preview-owned resources and dedicated preview workers.', + 'Cleanup of branch-scoped workers can also remove preview-only service, DO, and route ownership.' + ], + facts: [ + { label: 'Best for', value: 'Preview lifecycle management' }, + { label: 'Registry backing', value: 'D1 (`devflare-registry` by default)' }, + { + label: 'Cleanup warning', + value: 'Dedicated preview workers may own more than just the script' + } + ], + sourcePages: ['packages/devflare/src/cli/commands/deploy.ts', 'README.md'], + sections: [ + { + id: 'registry-role', + title: 'Why the registry exists', + paragraphs: [ + 'Cloudflare discovery alone is not enough for clean preview lifecycle management. The D1-backed registry tracks scope and deployment records for reliable inspection and cleanup.', + 'Devflare creates and updates the registry as preview deploys happen, so `previews` and `cleanup` work from real state.' + ] + }, + { + id: 'useful-commands', + title: 'Core commands', + snippets: [ + { + title: 'PR-close cleanup job for a named preview scope', + description: + 'Turn the same cleanup command into reviewable automation so closed PR previews do not rely on memory.', + filename: '.github/workflows/preview-cleanup.yml', + language: 'yaml', + code: String.raw`name: Preview cleanup + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx --bun devflare previews bindings --scope pr-${'${{ github.event.pull_request.number }}'} + - run: bunx --bun devflare previews cleanup --scope pr-${'${{ github.event.pull_request.number }}'} --apply` + }, + { + title: 'Preview lifecycle commands', + language: 'bash', + code: String.raw`bunx --bun devflare previews +bunx --bun devflare previews bindings --scope next +bunx --bun devflare previews cleanup --scope next --apply +bunx --bun devflare previews cleanup --all --apply` + } + ], + bullets: [ + '`previews` โ€” summary view of preview scopes.', + '`bindings --scope ` โ€” which workers reference one named scope.', + 'Prefer explicit scope selectors when you know the target; reserve broad cleanup for when the whole fleet needs attention.', + 'Without `--scope`, `cleanup` respects `DEVFLARE_PREVIEW_IDENTIFIER`, `DEVFLARE_PREVIEW_PR`, or `DEVFLARE_PREVIEW_BRANCH`, then falls back to the synthetic `preview` scope. Use `--all` for every discovered scope.' + ] + }, + { + id: 'cleanup-shape', + title: 'Cleanup should be specific', + bullets: [ + 'Without `--apply`, cleanup runs as a dry run โ€” showing what would be removed without touching anything.', + 'With `--apply`, it deletes preview-only resources and can delete dedicated preview worker scripts.', + 'Stable shared workers are not deleted; same-worker uploads only lose matching preview-scoped resources.', + 'Analytics Engine datasets and Browser Rendering bindings are reported as warnings. Hyperdrive cleanup only removes configs that already exist.' + ], + callouts: [ + { + tone: 'accent', + title: 'Good cleanup hygiene', + body: [ + 'Use the most specific selector you can. Cleanup is easier to trust when the target is obvious.' + ] + }, + { + tone: 'warning', + title: 'Not every preview-looking thing is deletable', + body: [ + 'Browser Rendering has no account-scoped resource, Analytics Engine datasets are created on first write, and Hyperdrive cleanup can only remove existing preview configs. The command tells you.' + ] + } + ] + } + ] + }, + { + slug: 'testing-and-automation', + group: 'Ship & operate', + navTitle: 'Testing & automation', + readTime: '5 min read', + eyebrow: 'Validation', + title: 'Test the runtime shape you ship, keep automation thin', + summary: + 'Local harness detail stays on the testing pages. This page covers what gets promoted into CI and how automation stays observable.', + description: + 'The local harness pages own `createTestContext()` and binding nuance. This page owns which checks move into preview validation and release automation.', + highlights: [ + 'Use `testing-overview`, `create-test-context`, and binding guides as the local-testing references.', + 'Carry only the timing rules that matter in CI: `cf.worker.fetch()` does not drain all `waitUntil()` work; queue, scheduled, and tail helpers do.', + 'Promote a small number of runtime-shaped smoke checks into CI.', + 'Keep deploy execution and feedback separate.' + ], + facts: [ + { label: 'Best for', value: 'CI testing policy and preview validation' }, + { label: 'Local harness owner', value: '`/docs/create-test-context` plus binding guides' }, + { label: 'Important nuance', value: '`cf.worker.fetch()` is not a full `waitUntil()` drain' }, + { label: 'Workflow companion', value: '`/docs/github-workflows`' } + ], + sourcePages: ['packages/devflare/src/test/simple-context.ts', 'README.md'], + sections: [ + { + id: 'ownership', + title: 'Let the local testing pages own local harness detail', + paragraphs: [ + 'This page used to repeat too much of the local harness story. The better split is simpler: keep `createTestContext()` behavior, autodiscovery, and binding-specific harness detail on the dedicated testing pages, then use this page for the question โ€œwhat should actually run in automation?โ€', + 'That keeps local test design and CI policy from drifting into two slightly different copies of the same documentation.' + ], + cards: [ + { + href: docsLink('testing-overview'), + label: 'Testing', + meta: 'Map', + title: 'Testing overview', + body: 'Use the map page first when you need to choose between starter tests, the harness page, binding-specific guides, runtime context, or CI-facing validation.' + }, + { + href: docsLink('create-test-context'), + label: 'Testing', + meta: 'Harness', + title: 'createTestContext()', + body: 'This is the canonical page for autodiscovery, helper timing, transport-aware round-trips, and the real `cf.*` helper behavior.' + }, + { + href: docsLink('binding-testing-guides'), + label: 'Testing', + meta: 'Binding index', + title: 'Binding testing guides', + body: 'Open these when the binding changes the honest testing posture and the local harness rules are no longer one-size-fits-all.' + } + ], + callouts: [ + { + tone: 'info', + title: 'Cleaner split keeps both pages better', + body: [ + 'Harness pages own local helper behavior. This page owns what gets promoted and how automation stays readable.' + ] + } + ] + }, + { + id: 'automation-timing', + title: 'Timing rules that matter in CI', + paragraphs: [ + 'Automation does not need the full harness manual, but it needs the timing rules that produce flaky checks or false confidence.', + 'Promote the check that matches the behavior you need to trust.' + ], + table: { + headers: ['When the check depends on...', 'Prefer', 'Why'], + rows: [ + [ + '`waitUntil()` side effects from an HTTP handler', + 'Assert the side effect directly or move to a higher-fidelity check.', + '`cf.worker.fetch()` returns when the handler resolves, not when every background task drains.' + ], + [ + 'Queue, scheduled, or tail background work', + '`cf.queue.trigger()`, `cf.scheduled.trigger()`, or `cf.tail.trigger()`', + 'Those helpers wait for their background work before they return, so they are a better fit for async side-effect assertions.' + ], + [ + 'Binding-specific or transport-specific behavior', + 'The binding guide or `create-test-context` page first', + 'Different bindings and bridge-backed values have different honest harness rules, and the local testing pages already own those details.' + ] + ] + }, + callouts: [ + { + tone: 'warning', + title: 'Wrong completion contract = flaky CI', + body: [ + 'If a test depends on `waitUntil()` effects being complete, a plain `cf.worker.fetch()` assertion may be too early.' + ] + } + ] + }, + { + id: 'promotion-path', + title: 'Promote the smallest useful checks', + steps: [ + 'Prove the behavior locally with `createTestContext()` or the binding-specific guide first.', + 'Choose one or two runtime-shaped smoke checks worth rerunning in CI because they protect the deploy boundary.', + 'Use preview validation when routing, preview-owned resources, or branch-scoped behavior is the real risk instead of trying to force every concern through one unit-style check.', + 'Publish one visible summary or feedback artifact so reviewers can tell what passed without spelunking through raw logs.' + ], + cards: [ + { + href: docsLink('preview-operations'), + label: 'Ship & operate', + meta: 'Preview lifecycle', + title: 'Preview operations', + body: 'Use the preview page when a runtime check depends on preview-scoped resources, scope inspection, or cleanup behavior.' + }, + { + href: docsLink('production-deploys'), + label: 'Ship & operate', + meta: 'Deploy targets', + title: 'Production deploys', + body: 'Use the production page when the check is really about the deploy target, compiled output, or preflight inspection before release.' + }, + { + href: docsLink('github-workflows'), + label: 'Ship & operate', + meta: 'CI/CD', + title: 'GitHub workflows', + body: 'Use the workflow page when those promoted checks need to become reviewable Actions jobs with explicit triggers, permissions, and feedback.' + } + ] + }, + { + id: 'automation-shape', + title: 'Automation stays thin and observable', + paragraphs: [ + 'Deploy logic and GitHub feedback are separate. Cloudflare state changes stay independent from PR comments, deployment records, or other reporting.', + 'Caller workflows own branch naming, permissions, and feedback decisions. Reusable actions focus on one deploy or one reporting job.' + ], + bullets: [ + 'One package, one target, one visible result per workflow lane.', + 'Split deploy from feedback so reporting can fail or retry independently.', + 'Prefer summaries, PR comments, or deployment records over raw logs.' + ], + snippets: [ + { + title: 'Thin preview deploy step', + filename: '.github/workflows/preview.yml', + language: 'yaml', + code: thinPreviewDeployStepCode + } + ], + callouts: [ + { + tone: 'info', + title: 'Thin workflows age better', + body: [ + 'When a release is stressful, a small workflow that says what it deploys and what it reports is easier to trust.' + ] + } + ], + cards: [ + { + href: docsLink('github-workflows'), + label: 'Ship & operate', + meta: 'CI/CD', + title: 'GitHub workflows', + body: 'The workflow page owns the supported GitHub Actions patterns for impact checks, reusable actions, preview lanes, production lanes, PR feedback, and cleanup.' + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/ship-operate/shared.ts b/apps/documentation/src/lib/docs/content/ship-operate/shared.ts new file mode 100644 index 0000000..293c23c --- /dev/null +++ b/apps/documentation/src/lib/docs/content/ship-operate/shared.ts @@ -0,0 +1,91 @@ +import type { DocPage } from '../../types' + +export const workflowRepoBase = 'https://github.com/Refzlund/devflare/blob/next/.github/workflows' + +export const workflowActionSourceBase = + 'https://github.com/Refzlund/devflare/blob/next/.github/actions' + +export const workflowScriptBase = 'https://github.com/Refzlund/devflare/blob/next/.github/scripts' + +export const workflowActionRepo = 'Refzlund/devflare/.github/actions' + +export const workflowActionRef = 'next' + +export const workflowLink = (file: string): string => `${workflowRepoBase}/${file}` + +export const workflowActionSourceLink = (action: string): string => + `${workflowActionSourceBase}/${action}/action.yml` + +export const workflowScriptLink = (file: string): string => `${workflowScriptBase}/${file}` + +export const workflowActionUse = (action: string): string => + `${workflowActionRepo}/${action}@${workflowActionRef}` + +export const docsLink = (slug: string): string => `/docs/${slug}` + +export const setupWorkspaceActionCode = String.raw`- uses: ${workflowActionUse('devflare-setup-workspace')} + with: + working-directory: .` + +export const deployImpactActionCode = String.raw`- name: Resolve documentation preview impact + id: impact + uses: ${workflowActionUse('devflare-deploy-impact')} + with: + target-package: documentation + default-branch: \${{ github.event.repository.default_branch }} + event-name: \${{ github.event_name }} + event-action: \${{ github.event.action || '' }} + push-before: \${{ github.event.before || '' }} + pull-request-base-sha: \${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: \${{ github.event.pull_request.head.sha || '' }}` + +export const previewDeployActionCode = String.raw`- id: pr-deploy + uses: ${workflowActionUse('devflare-deploy')} + with: + working-directory: apps/documentation + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + deploy-command: bun run deploy -- + preview-scope: \${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + deploy-message: Documentation PR preview \${{ github.sha }} (run \${{ github.run_id }}) + deploy-tag: documentation-pr-preview-\${{ github.run_id }} + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` + +export const productionDeployActionCode = String.raw`- id: deploy + uses: ${workflowActionUse('devflare-deploy')} + with: + working-directory: apps/documentation + install-working-directory: . + deploy-command: bun run deploy -- + production: 'true' + deploy-message: Documentation production \${{ github.sha }} (run \${{ github.run_id }}) + deploy-tag: documentation-production-\${{ github.run_id }} + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` + +export const githubFeedbackCommentCode = String.raw`- uses: ${workflowActionUse('devflare-github-feedback')} + with: + github-token: \${{ github.token }} + mode: comment + operation: report + status: success + title: Documentation PR preview + comment-key: pr-deployment-status + comment-section-key: documentation-preview + pr-number: \${{ needs.resolve-context.outputs.pr-number }} + preview-url: \${{ steps.pr-deploy.outputs.preview-url }} + version-id: \${{ steps.pr-deploy.outputs.version-id }} + log-url: \${{ github.server_url }}/\${{ github.repository }}/actions/runs/\${{ github.run_id }}` + +export const thinPreviewDeployStepCode = String.raw`- id: deploy + uses: ${workflowActionUse('devflare-deploy')} + with: + working-directory: apps/documentation + deploy-command: bun run deploy -- + preview-scope: \${{ github.head_ref || github.ref_name }} + verify-deployment: 'false' + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` diff --git a/apps/documentation/src/lib/docs/content/start-here.ts b/apps/documentation/src/lib/docs/content/start-here.ts new file mode 100644 index 0000000..73b2ea1 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here.ts @@ -0,0 +1 @@ +export { startHereDocs } from './start-here/index' diff --git a/apps/documentation/src/lib/docs/content/start-here/index.ts b/apps/documentation/src/lib/docs/content/start-here/index.ts new file mode 100644 index 0000000..623289b --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/index.ts @@ -0,0 +1,11 @@ +import { startHereDocsPart1 } from './part-1' +import { startHereDocsPart2 } from './part-2' +import { startHereDocsPart3 } from './part-3' +import { startHereDocsPart4 } from './part-4' + +export const startHereDocs = [ + startHereDocsPart1, + startHereDocsPart2, + startHereDocsPart3, + startHereDocsPart4 +].flat() diff --git a/apps/documentation/src/lib/docs/content/start-here/part-1.ts b/apps/documentation/src/lib/docs/content/start-here/part-1.ts new file mode 100644 index 0000000..620f330 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/part-1.ts @@ -0,0 +1,635 @@ +import type { DocPage } from '../../types' +import { + browserBindingsStructure, + browserConfigCode, + browserRouteCode, + counterObjectCode, + counterTransportCode, + counterValueCode, + docsLink, + durableObjectBindingsStructure, + durableObjectConfigCode, + durableObjectRouteCode, + firstWorkerConfigCode, + firstWorkerFetchCode, + firstWorkerStructure, + firstWorkerTestCode, + r2BindingsStructure, + r2ConfigCode, + r2RouteCode, + requestContextHelperCode, + routedWorkerConfigCode, + routedWorkerFetchCode, + routedWorkerIndexRouteCode, + routedWorkerStructure +} from './shared' +import { cloudflarePlatformSupportCards } from './support-coverage' + +export const startHereDocsPart1: DocPage[] = [ + { + slug: 'what-devflare-is', + group: 'Quickstart', + navTitle: 'Why Devflare', + readTime: '7 min read', + eyebrow: 'Why it helps', + title: 'Why Devflare feels better than stitching Cloudflare Worker workflows together by hand', + summary: + 'Devflare gives you one clearer story for config, worker compilation, local development, runtime helpers, testing, and deploy flows so a Worker app can stay small at the start and still stay coherent as it grows.', + description: + 'The goal is not to hide Cloudflare. The goal is to keep the files you edit small and obvious, then give you a smoother path from one worker to routing, bindings, frameworks, previews, and automation.', + highlights: [ + 'Start with one config file, one dev command, generated types, and runtime-shaped tests instead of assembling each piece separately.', + 'Keep the code surface split by job: `devflare/config`, `devflare/runtime`, `devflare/test`, and dedicated `vite` or `sveltekit` lanes instead of one giant catch-all entrypoint.', + 'Keep Worker code worker-first: explicit surfaces, small handlers, readable config, and Rolldown-backed worker compilation before framework glue enters the picture.', + 'Scale into Vite and SvelteKit without replacing the worker-first story; in local dev, framework endpoints can still talk to Cloudflare-shaped bindings through the bridge-backed platform surface.', + 'Keep preview, cleanup, and production operations explicit instead of burying them in undocumented shell habits.', + 'Stay close to the real Cloudflare platform contract instead of learning a fantasy abstraction you have to unlearn later.' + ], + facts: [ + { + label: 'Best for', + value: 'Teams that want Cloudflare power without accumulating setup glue' + }, + { + label: 'Architecture shape', + value: 'Config, runtime, tests, framework integration, and Cloudflare ops stay separate' + }, + { + label: 'Build lane', + value: 'Rolldown composes worker and Durable Object artifacts; Vite stays optional' + }, + { + label: 'Still true', + value: 'Cloudflare limits and Wrangler-compatible output still matter' + } + ], + sourcePages: [ + 'README.md', + 'packages/devflare/README.md', + 'package.json', + 'config-entry.ts', + 'context.ts', + 'browser.ts', + 'worker-entry/composed-worker.ts', + 'worker-entry/routes.ts', + 'bundler/rolldown-shared.ts', + 'schema-bindings.ts', + 'ref.ts', + 'bridge/proxy.ts', + 'bridge/server.ts', + 'dev-server/server.ts', + 'src/runtime/middleware.ts', + 'remote-ai.ts', + 'remote-vectorize.ts', + 'preview-resources.ts', + 'vite/plugin.ts', + 'sveltekit/platform.ts', + 'cli/commands/deploy.ts', + 'cli/commands/previews.ts', + 'schema-runtime.ts', + 'packages/devflare/src/test/offline-bindings.ts', + 'packages/devflare/src/test/containers.ts', + 'packages/devflare/src/test/utilities.ts', + 'apps/documentation/src/lib/docs/content/bindings/*' + ], + sections: [ + { + id: 'why-teams-reach', + title: 'Why teams reach for Devflare in the first place', + description: + 'Most people do not adopt Devflare because they want more abstraction. They adopt it because raw Worker projects can accumulate too many small decisions in too many places.', + paragraphs: [ + 'Without some structure, config lives in one file, generated artifacts in another, tests invent their own fake runtime, and preview or deploy behavior becomes whichever shell snippet the team last copied forward.', + 'Devflare gives those pieces one authored story: readable config, worker-shaped runtime helpers, generated worker composition, a bridge-backed local loop, and deploy or preview flows that stay explicit instead of magical.' + ], + cards: [ + { + title: 'Less glue code', + body: 'Keep stable intent in authored config instead of scattering worker names, resource ids, and generated file edits across the repo.' + }, + { + title: 'Split by responsibility', + body: 'Config authoring, runtime helpers, tests, framework hooks, and Cloudflare operations live in separate lanes instead of one catch-all surface.' + }, + { + title: 'Worker-aware compilation', + body: 'Author routes, surfaces, and Durable Objects as app code, then let Devflare and Rolldown compose the runtime-facing artifacts.' + }, + { + title: 'Cleaner local and framework loop', + body: 'Use one worker-aware development story that can stay worker-only or plug into Vite and SvelteKit when the package actually needs them.' + }, + { + title: 'Tests that resemble production', + body: 'Reach for the built-in runtime-shaped test harness before custom mocks drift away from how the Worker actually behaves.' + } + ] + }, + { + id: 'why-the-codebase-stays-coherent', + title: 'Why the codebase stays coherent as the app grows', + description: + 'The implementation splits by environment and lifecycle so the worker story can grow without collapsing into one giant tool blob.', + paragraphs: [ + "`devflare/config` is for authored config, `devflare/runtime` is for worker code, `devflare/test` is for harnesses, and `devflare/vite` or `devflare/sveltekit` only join the picture when the package grows into a real app host. That split is one of the package's quiet strengths.", + 'The build and local-dev story stays honest too. Rolldown is the worker builder, generated entrypoints keep worker surfaces explicit, and Vite or SvelteKit can sit outside the worker runtime instead of swallowing it.' + ], + cards: [ + { + title: 'Split package surfaces', + body: 'Different public entrypoints exist because config authoring, runtime code, tests, framework hosting, and Cloudflare operations are different jobs.' + }, + { + title: 'Rolldown owns worker artifacts', + body: 'Worker and Durable Object bundles are composed and validated for Cloudflare compatibility instead of being treated as generic JavaScript output.' + }, + { + title: 'Bridge-backed framework dev', + body: 'When a package uses Vite or SvelteKit, Devflare keeps Miniflare or workerd on one side, the app host on the other, and bridges bindings back into the framework dev server.' + }, + { + title: 'Framework endpoints can still reach worker bindings', + body: 'In local dev, the framework lane can read Cloudflare-shaped bindings through the bridge-backed platform surface instead of needing a second fake environment.' + } + ], + callouts: [ + { + tone: 'accent', + title: 'Vite is additive here', + body: [ + 'Vite and SvelteKit are optional outer hosts. The worker runtime, routes, bindings, and generated artifacts remain the core story.' + ], + cta: { + description: 'Want support for your framework of choice?', + label: 'Open an issue', + href: 'https://github.com/Refzlund/devflare/issues' + } + } + ] + }, + { + id: 'support-coverage', + title: 'What Devflare supports across Cloudflare platform features', + description: + 'Every native binding or platform lane in the binding docs is listed here with its current Devflare support level and a direct link to the page with config, examples, tests, and boundary notes. Hover a label to see what that support level means.', + cards: cloudflarePlatformSupportCards + }, + { + id: 'devflare-enhancements', + title: 'What Devflare adds on top of raw Cloudflare workflows', + description: + 'These are the pieces you use while building an app, not concepts you need to memorize before the first route works.', + cards: [ + { + label: 'Runtime', + title: 'Runtime context helpers', + body: 'Helper code can read the active request, env, ctx, event, and `locals` without threading the event through every function call.', + href: docsLink('runtime-context') + }, + { + label: 'Runtime', + title: '`sequence(...)` middleware', + body: 'Request-wide middleware gets a named helper instead of forcing every app to reinvent the same fetch wrapper.', + href: docsLink('sequence-middleware') + }, + { + label: 'Testing', + title: 'Runtime-shaped unit testing and the smart bridge', + body: 'The default test harness boots a real worker-shaped environment and uses the bridge so tests can talk to workers, bindings, queues, services, and other surfaces without inventing a second fake runtime.', + href: docsLink('create-test-context') + }, + { + label: 'Runtime', + title: '`transport.ts`', + body: 'Custom bridge-backed values can round-trip as real classes instead of collapsing into plain JSON when the worker boundary needs richer types.', + href: docsLink('transport-file') + }, + { + label: 'Composition', + title: 'Multi-worker config references', + body: '`ref()` and service bindings let one worker depend on another explicitly so config, generated types, local tests, and compiled output all follow the same relationship.', + href: docsLink('multi-workers') + }, + { + label: 'Configuration', + title: 'Preview scopes and preview bindings', + body: 'Preview environments can get their own scoped bindings and disposable infrastructure instead of borrowing production resources and hoping everyone remembers that later.', + href: docsLink('config-previews') + }, + { + label: 'Types', + title: 'Generated types', + body: 'Generate `env.d.ts` and typed service contracts from the config so the worker surface, bindings, and entrypoints stay aligned with the app you actually run.', + href: docsLink('generated-types') + }, + { + label: 'Operations', + title: 'Binding-aware deploys', + body: 'Build, preview, and production commands compile the same binding-aware config into Wrangler-compatible output instead of making you maintain a second deploy-only definition.', + href: docsLink('production-deploys') + }, + { + label: 'Configuration', + title: '`.env` config-time variables', + body: 'Devflare reads `.env` while evaluating `devflare.config.*`, which keeps build-time inputs available without blurring them together with runtime `vars` and `secrets`.', + href: docsLink('config-basics') + }, + { + label: 'Frameworks', + title: 'Full Vite support', + body: 'If the package is genuinely a Vite app, Devflare plugs into Vite as the outer host while still keeping worker-aware config, bindings, and generated Cloudflare output aligned underneath it.', + href: docsLink('vite-standalone') + } + ], + callouts: [ + { + tone: 'success', + title: 'This is the real distinction', + body: [ + 'Cloudflare gives you the platform primitives. Devflare adds the authored config model, runtime helpers, bridge-backed local dev, test harnesses, typed generation, and preview-aware workflows that make those primitives feel like one coherent application story.' + ] + }, + { + tone: 'accent', + title: 'Composable infrastructure is intentional', + body: [ + 'Devflare is designed around small, explicit files and runtime surfaces: `src/fetch.ts`, `src/queue.ts`, `src/do/**/*.ts`, route modules, and runtime APIs that let those pieces compose cleanly instead of collapsing into one monolithic worker file.', + 'That same shape works for a tiny project and for a larger enterprise repo. You can keep responsibilities split by surface, file, and package without losing the thread of one coherent Cloudflare application.' + ], + cta: { + description: 'Want to see the package and repo shape Devflare is optimized for?', + label: 'Open the project architecture guide', + href: docsLink('project-architecture') + } + } + ] + }, + { + id: 'what-you-get-day-one', + title: 'What you get on day one', + steps: [ + 'Author one readable `devflare.config.ts` instead of reverse-engineering a generated deployment shape.', + 'Point `files.fetch` at one small handler and let Devflare manage the worker-oriented plumbing around it.', + 'Generate `env.d.ts` so bindings and helper surfaces stay typed without hand-maintained drift.', + 'Use the built-in test harness so your first tests look like the runtime you will actually ship.', + 'Add routes, bindings, frameworks, or preview flows only when the package truly needs them.' + ], + snippets: [ + { + title: 'The smallest Devflare project still looks like a real project', + description: + 'Two authored files teach the whole loop, while generated pieces stay visible without becoming your source of truth.', + activeFile: 'devflare.config.ts', + structure: [ + ...firstWorkerStructure, + { path: '.devflare', kind: 'folder', muted: true }, + { path: '.devflare/worker-entrypoints/main.ts', muted: true } + ], + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 7]], + code: firstWorkerConfigCode + }, + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[3, 5]], + code: firstWorkerFetchCode + } + ] + } + ], + callouts: [ + { + tone: 'success', + title: 'The point is fast confidence, not more ceremony', + body: [ + 'If Devflare is helping, your first win should be a small Worker you can understand, run, and test quickly โ€” not a larger setup burden.' + ] + } + ] + }, + { + id: 'where-it-keeps-helping', + title: 'Where it keeps paying off later', + bullets: [ + 'The package surface stays split by job as the app grows, so config authoring, runtime code, tests, framework hooks, and Cloudflare operations do not collapse into one file or one import path.', + 'Rolldown keeps owning worker and Durable Object compilation, which is why the app can grow new surfaces without hand-maintaining a giant entrypoint.', + 'If the package later needs Vite or SvelteKit, Devflare layers that in as an outer host and uses the bridge-backed platform surface so framework endpoints can still interact with worker bindings in local dev.', + 'Preview scopes, cleanup flows, production operations, and testing helpers stay connected to the same authored config and CLI instead of branching into separate half-documented workflows.' + ] + } + ] + }, + { + slug: 'documentation-contract', + group: 'Quickstart', + navTitle: 'Contract map', + sidebarHidden: true, + readTime: '5 min read', + eyebrow: 'Docs model', + title: 'See how the site model and published `LLM.md` stay aligned', + summary: + 'The documentation site now owns the authored docs model, while `packages/devflare/LLM.md` remains the generated one-file export shipped with the package.', + description: + 'The older split handbook content has been folded into `apps/documentation/src/lib/docs/content*.ts`. The site now carries that material as smaller task-focused routes, and the published `packages/devflare/LLM.md` file is generated from the same model when you want one flattened handbook export.', + highlights: [ + 'The structured docs model in `apps/documentation/src/lib/docs/content*.ts` is now the authoritative authoring layer.', + 'Task-focused site routes and the generated handbook export come from the same underlying model.', + 'The package-level `LLM.md` file is generated from the site model and copied into the package before publish.', + 'Focused package references such as `README.md` and implementation files can still inform a page without leaking into the page header.' + ], + facts: [ + { + label: 'Authoritative authoring layer', + value: '`apps/documentation/src/lib/docs/content*.ts`' + }, + { + label: 'Primary reading surfaces', + value: 'Task-focused `/docs/*` routes plus `/llm.md` and `/llm.txt` exports' + }, + { + label: 'Refresh commands', + value: + '`bun run llm:generate` from `apps/documentation`, or the same command from `packages/devflare` when you also want the packaged copy refreshed' + } + ], + sourcePages: [ + 'packages/devflare/README.md', + 'packages/devflare/src/config/schema.ts', + 'packages/devflare/src/config/schema-bindings.ts', + 'packages/devflare/src/dev-server/server.ts', + 'packages/devflare/src/cli/commands/deploy.ts', + 'packages/devflare/src/test/simple-context.ts', + 'llm.ts', + 'llm-documents.ts', + 'generate-llm.ts' + ], + sections: [ + { + id: 'authoritative-layers', + title: 'Know which layer is authoritative now', + paragraphs: [ + 'The structured documentation model in `apps/documentation/src/lib/docs/content*.ts` is now the source of truth for the authored Devflare handbook. The older split package docs have been folded into that model so the site and exported handbook stay aligned.', + 'The site breaks that material into smaller task-focused routes, while the generated handbook turns the same model into one-file exports for search, review, and package shipping.', + 'The generated `/llm.md` export is the fuller one-file handbook, while `/llm.txt` is the stricter text-oriented subset from the same model and intentionally omits handbook-only sections such as the documentation contract. The published `packages/devflare/LLM.md` file is copied from `/llm.md` before packaging, and none of those exports are meant to be hand-edited source authoring.' + ], + cards: [ + { + title: 'Structured docs model', + body: '`apps/documentation/src/lib/docs/content*.ts` now holds the authored handbook copy, page structure, examples, and task-first route organization.' + }, + { + title: 'Task-focused site routes', + body: 'The site favors smaller routes aimed at one job to be done instead of mirroring the old handbook structure page for page.' + }, + { + title: 'Published handbook export', + body: 'Use `/llm.md` for the fuller generated handbook, `/llm.txt` for the stricter text-oriented subset, and remember that `packages/devflare/LLM.md` is copied from `/llm.md` before publish time.' + } + ], + callouts: [ + { + tone: 'accent', + title: 'The safest drift rule', + body: [ + 'If handbook coverage changes, update the matching site pages first, then regenerate the package handbook. If `packages/devflare/LLM.md` says something the site model does not back up, fix the site model and regenerate instead of patching the handbook by hand.' + ] + } + ] + }, + { + id: 'raw-to-site-map', + title: 'See where the same docs model shows up', + description: + 'The site and handbook outputs are different reading surfaces backed by one model, not separate sources of truth.', + table: { + headers: ['Surface', 'Best when', 'Backed by'], + rows: [ + [ + '/docs/* routes', + 'You are reading one topic in the site and want navigation, context, and examples inline.', + '`apps/documentation/src/lib/docs/content*.ts`' + ], + [ + '/llm.md and /llm.txt', + 'You want the generated handbook as one file: `/llm.md` for the fuller export, `/llm.txt` for the stricter text-oriented subset that omits handbook-only sections such as the documentation contract.', + 'Generated from the same docs model.' + ], + [ + '`packages/devflare/LLM.md`', + 'You want the published one-file handbook that ships with the package.', + 'Copied from the generated docs export before packaging.' + ] + ] + } + }, + { + id: 'how-to-use-both', + title: 'Use the site for tasks and the handbook for one-file reading', + snippets: [ + { + title: 'Wire docs generation and drift checks into the repo scripts', + description: + 'Use package scripts and CI to keep the authored site model, generated site exports, and packaged handbook moving together.', + activeFile: 'package.json', + structure: [ + { path: 'package.json' }, + { path: '.github', kind: 'folder' }, + { path: '.github/workflows/docs-quality.yml' }, + { path: 'apps/documentation', kind: 'folder', muted: true }, + { path: 'packages/devflare/LLM.md', muted: true } + ], + files: [ + { + path: 'package.json', + language: 'json', + code: String.raw`{ + "scripts": { + "docs:generate": "bun run --cwd apps/documentation llm:generate && bun run --cwd packages/devflare llm:generate", + "docs:check": "bun run devflare:docs-integrity && bun run --cwd apps/documentation check" + } +}` + }, + { + path: '.github/workflows/docs-quality.yml', + language: 'yaml', + code: String.raw`name: Documentation quality + +on: + pull_request: + paths: + - "apps/documentation/**" + - "packages/devflare/LLM.md" + - "packages/devflare/tests/unit/docs/**" + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run docs:generate + - run: bun run docs:check` + } + ] + }, + { + title: 'Regenerate the handbook from the site model', + language: 'bash', + code: String.raw`bun run --cwd apps/documentation llm:generate +bun run --cwd packages/devflare llm:generate +bun run devflare:docs-integrity` + } + ], + steps: [ + 'Start from the task-focused site page when you need to build, review, or debug one specific part of Devflare.', + 'Use `/llm.md` when you want the fullest one-file handbook, `/llm.txt` when you want the stricter text-oriented subset, or the published `packages/devflare/LLM.md` file when you want the package copy that ships.', + 'Run `bun run llm:generate` from `apps/documentation` when you are editing the site model, or from `packages/devflare` when you need the packaged `LLM.md` copy refreshed too.', + 'Let build and prepare hooks regenerate the handbook outputs instead of hand-editing `LLM.md`.' + ], + callouts: [ + { + tone: 'success', + title: 'The intended reading pattern', + body: [ + 'Read the site by job to be done, and use the package-level `LLM.md` when you want the same material in one file.' + ] + } + ] + }, + { + id: 'drift-checks', + title: 'A good docs drift check is small and specific', + bullets: [ + 'Update the site pages first, then regenerate the handbook outputs.', + 'If the site and `packages/devflare/LLM.md` disagree, fix `apps/documentation/src/lib/docs/content*.ts` and regenerate instead of patching the export by hand.', + 'If a concept stops fitting the current site structure, add or split a page instead of hiding the change in generated output.', + 'Never hand-edit generated `packages/devflare/LLM.md`; regenerate it from the site model after you update the underlying docs.' + ] + } + ] + }, + { + slug: 'first-worker', + group: 'Quickstart', + navTitle: 'Your first worker', + readTime: '5 min read', + eyebrow: 'First setup', + title: 'Build your first Devflare worker with the smallest safe setup', + summary: + 'Start with one config file, one fetch handler, and generated types before you branch into routes, bindings, frameworks, or a deeper test setup.', + summaryHidden: true, + description: + 'This page keeps the first pass tiny: explicit `files.fetch`, one small handler, and just enough commands to install Devflare, generate types, and run the worker locally.', + descriptionHidden: true, + articleNavigationHidden: true, + highlights: [ + 'Use `devflare/config` in config files and `devflare/runtime` in worker code.', + 'Start with `src/fetch.ts` instead of a full route tree unless you actually need multiple leaves.', + 'Generate `env.d.ts` early so bindings and helper surfaces stay typed.', + 'Add routing, storage, workers, frameworks, and deeper tests only when the worker actually asks for them.' + ], + facts: [ + { label: 'Best for', value: 'New packages and first-time Devflare users' }, + { label: 'Smallest safe shape', value: 'One config and one fetch handler' }, + { label: 'First commands', value: '`bun add -d devflare`, then `types`, then `dev`' } + ], + sourcePages: ['README.md', 'packages/devflare/README.md'], + sections: [ + { + id: 'get-started', + title: 'Get started', + steps: [ + 'Run `bun add -d devflare`.', + 'Create `devflare.config.ts` with an explicit fetch entry.', + 'Add `src/fetch.ts` with one event-first handler.', + 'Run `devflare types` before guessing env types by hand.', + 'Run `devflare dev` and make sure the smallest worker works before you add anything else.' + ], + snippets: [ + { + title: 'Install Devflare and boot the worker', + language: 'bash', + code: String.raw`bun add -d devflare +bunx --bun devflare types +bunx --bun devflare dev` + }, + { + title: 'Start with two files, not a framework maze', + description: + 'Open the config first, then the fetch handler. That is enough to run, test, and understand before you add anything bigger.', + activeFile: 'devflare.config.ts', + structure: firstWorkerStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 8]], + code: firstWorkerConfigCode + }, + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[3, 5]], + code: firstWorkerFetchCode + } + ] + } + ] + }, + { + id: 'start-building', + title: 'Start building', + description: 'Pick the next thing you actually need once the first worker is running.', + cards: [ + { + label: 'Testing', + title: 'Write your first unit test', + body: 'Use the built-in harness before you invent mocks or wrappers.', + href: docsLink('first-unit-test') + }, + { + label: 'Bindings', + title: 'Try your first bindings', + body: 'Make one Durable Object, one R2 bucket, or one browser-backed route work without overcomplicating the package.', + href: docsLink('first-bindings') + }, + { + label: 'HTTP', + title: 'Need multiple URLs?', + body: 'Add `src/routes/**` when a route tree is easier to reason about than one large fetch handler.', + href: docsLink('http-routing') + }, + { + label: 'Bindings', + title: 'Need storage choices?', + body: 'Choose between KV, D1, R2, and Hyperdrive before you open the binding guide with the config and examples.', + href: docsLink('storage-bindings') + }, + { + label: 'Bindings', + title: 'Need state or background work?', + body: 'Use the state and async patterns page to decide between Durable Objects, queues, or a mix of both.', + href: docsLink('durable-objects-and-queues') + }, + { + label: 'Composition', + title: 'Need worker composition?', + body: 'Use service bindings and `ref()` when another worker boundary is real, not just when one file feels crowded.', + href: docsLink('multi-workers') + }, + { + label: 'Frameworks', + title: 'Need a framework host?', + body: 'Only opt into Vite-backed mode when the current package actually has a local Vite or framework app.', + href: docsLink('vite-standalone') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/start-here/part-2.ts b/apps/documentation/src/lib/docs/content/start-here/part-2.ts new file mode 100644 index 0000000..2e71504 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/part-2.ts @@ -0,0 +1,592 @@ +import type { DocPage } from '../../types' +import { + browserBindingsStructure, + browserConfigCode, + browserRouteCode, + counterObjectCode, + counterTransportCode, + counterValueCode, + docsLink, + durableObjectBindingsStructure, + durableObjectConfigCode, + durableObjectRouteCode, + firstWorkerConfigCode, + firstWorkerFetchCode, + firstWorkerStructure, + firstWorkerTestCode, + r2BindingsStructure, + r2ConfigCode, + r2RouteCode, + requestContextHelperCode, + routedWorkerConfigCode, + routedWorkerFetchCode, + routedWorkerIndexRouteCode, + routedWorkerStructure, + supportCoverageTooltips +} from './shared' + +export const startHereDocsPart2: DocPage[] = [ + { + slug: 'first-unit-test', + group: 'Quickstart', + navTitle: 'Your first unit test', + readTime: '4 min read', + eyebrow: 'Testing', + title: 'Write your first unit test with the built-in Devflare harness', + summary: + 'Take the same starter worker from the previous page and add one request test through `createTestContext()` so the first check uses the same runtime shape the worker will actually run.', + description: + 'You do not need a custom mock stack to get confidence. Keep `devflare.config.ts` and `src/fetch.ts` as they were, add one `tests/fetch.test.ts` file, and prove the worker responds once.', + highlights: [ + 'Keep the same `devflare.config.ts` and `src/fetch.ts`; add only one new test file.', + 'Use `createTestContext()` before you invent custom mocks.', + 'Hit the worker through `cf.worker.get()` for the first honest proof.', + 'Call `env.dispose()` when the suite is done so the runtime shuts down cleanly.' + ], + facts: [ + { label: 'Best for', value: 'The first runtime-shaped test in a new worker package' }, + { label: 'Main helper', value: '`createTestContext()` plus `cf.worker.get()`' }, + { label: 'First proof', value: 'One request, one status check, one response assertion' } + ], + sourcePages: ['README.md', 'packages/devflare/README.md', 'simple-context.ts', 'cf.ts'], + sections: [ + { + id: 'write-one-test', + title: 'Write one honest test', + paragraphs: [ + 'The easiest continuation from the first worker page is not a refactor. It is one new test file beside the same config and fetch handler.', + '`createTestContext()` gives that test the same runtime shape Devflare manages locally. Keep the first assertion narrow: one request, one status check, one response body. That already proves the worker, the harness, and your local setup are all talking to each other correctly.' + ], + snippets: [ + { + title: 'Keep the first worker, add one test file', + description: + 'The config and fetch handler stay exactly the same. The only new authored file is the test.', + activeFile: 'tests/fetch.test.ts', + structure: [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'tests', kind: 'folder' }, + { path: 'tests/fetch.test.ts' }, + { path: 'env.d.ts', muted: true } + ], + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + code: firstWorkerConfigCode + }, + { + path: 'src/fetch.ts', + language: 'ts', + code: firstWorkerFetchCode + }, + { + path: 'tests/fetch.test.ts', + language: 'ts', + focusLines: [[1, 12]], + code: firstWorkerTestCode + } + ] + } + ], + callouts: [ + { + tone: 'success', + title: 'Keep the first test boring', + body: [ + 'If the first test is obvious, failures are obvious too. That is what you want while the worker is still tiny.' + ] + } + ] + }, + { + id: 'what-it-unlocks', + title: 'What this unlocks next', + bullets: [ + 'You can keep the same harness when the worker grows routes, queue consumers, scheduled handlers, or other runtime surfaces.', + 'One request-level smoke test is still useful even after helpers and abstractions appear around the worker.', + 'When you need more test helpers, open `/docs/create-test-context` for the full helper map.' + ], + callouts: [ + { + tone: 'info', + title: 'The next docs page when tests grow up', + body: [ + 'Use `create-test-context` when you need more than one request test and want the full runtime helper surface laid out clearly.' + ] + } + ] + } + ] + }, + { + slug: 'first-bindings', + group: 'Quickstart', + navTitle: 'Your first bindings', + readTime: '6 min read', + eyebrow: 'Bindings', + title: 'Try your first bindings by growing the same worker one route at a time', + summary: + 'Take the same starter worker, split it into routes and helpers, then add one binding-backed route at a time so `src/fetch.ts` can stay small.', + description: + 'Keep one worker shape throughout: a tiny `src/fetch.ts`, a `src/routes/**` tree for leaf handlers, and one shared helper module that can read or write the active request context through `devflare/runtime` when that keeps the code cleaner.', + highlights: [ + 'Keep one worker shape throughout instead of treating each binding as a different mini-app.', + 'Use `files.routes` so Devflare route handling becomes explicit as soon as the package grows beyond one fetch file.', + 'Let shared helpers use `getFetchEvent()` and `locals` from `devflare/runtime` inside the active request trail instead of threading request ids, params, and request reads through every function.', + 'Generate `env.d.ts` after binding changes so the runtime surface stays typed.', + 'Add one binding-backed route at a time: first a counter, then a stored file, then one browser title read.' + ], + facts: [ + { + label: 'Best for', + value: 'Growing the first worker without turning `src/fetch.ts` into one crowded file' + }, + { label: 'Base shape', value: 'Tiny `src/fetch.ts` plus `src/routes/**` and shared helpers' }, + { label: 'Habit to keep', value: '`bunx --bun devflare types` after binding changes' } + ], + sourcePages: ['README.md', 'schema-bindings.ts', 'case3/*', 'case18/*', 'case19/*'], + sections: [ + { + id: 'pick-one-binding', + title: 'Keep the same worker, but split it into routes and helpers', + description: + 'The additive move after the first worker is not a different app. It is the same worker with one tiny fetch entry, one route tree, and one shared request helper.', + paragraphs: [ + 'Once the first worker responds and maybe already has one small test, the next step is to keep `src/fetch.ts` tiny. Let it do request-wide setup, then let `src/routes/**` own the individual URLs.', + "That shape also lets helper modules read the active request path, route params, request body, or request id through `getFetchEvent()` and `locals` without turning every function signature into plumbing." + ], + steps: [ + 'Keep `src/fetch.ts` for request-wide setup only.', + 'Add `files.routes` so the route tree is explicit in config.', + 'Move URL-specific work into `src/routes/**` files.', + 'Put shared request helpers in `src/lib/**` and let them read active request context from `devflare/runtime` when that keeps route files cleaner.', + 'Add one binding-backed route at a time instead of rebuilding the worker from scratch.' + ], + snippets: [ + { + title: 'Keep the same worker, but let routes and helpers do the growing', + description: + 'The fetch file stays tiny. Routes own URLs, and one helper module reads and writes the active request context through Devflare runtime when you need it.', + activeFile: 'src/fetch.ts', + structure: routedWorkerStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 11]], + code: routedWorkerConfigCode + }, + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[5, 10]], + code: routedWorkerFetchCode + }, + { + path: 'src/lib/request-context.ts', + language: 'ts', + focusLines: [[1, 18]], + code: requestContextHelperCode + }, + { + path: 'src/routes/index.ts', + language: 'ts', + focusLines: [[1, 8]], + code: routedWorkerIndexRouteCode + } + ] + } + ], + cards: [ + { + title: 'Durable Object', + body: 'Add one counter route that forwards to one object class and keeps state there.' + }, + { + title: 'R2 bucket', + body: 'Add one route that stores and reads one named file without bloating the global fetch file.' + }, + { + title: 'Browser Rendering', + body: 'Add one route that opens a page and returns its title so the browser binding stays obvious.' + } + ], + callouts: [ + { + tone: 'success', + title: 'This is still the same worker', + body: [ + 'You are not swapping architectures here. You are just letting `src/fetch.ts` stay small while routes and helpers take the extra responsibility.' + ] + } + ] + }, + { + id: 'durable-object-counter', + title: 'Add one Durable Object-backed route', + description: + 'Keep the same route-based worker and add one counter route, one transport file, and one object class.', + paragraphs: [ + 'Use the same `src/fetch.ts`, the same request helper, and the same route tree. The new work lives in one route file that talks to one Durable Object namespace through a custom `increment()` method.', + 'That keeps the route honest: the HTTP path stays in `src/routes/counter.ts`, the stateful method stays in `src/do/counter.ts`, and `src/transport.ts` restores the returned value object cleanly on the worker side.' + ], + snippets: [ + { + title: 'Same worker, now add a counter route, transport, and one Durable Object', + description: + 'The familiar fetch file and helper stay in place. You add the binding config, one transport file, the counter route, and the object class that exposes a custom `increment()` method.', + activeFile: 'src/routes/counter.ts', + structure: durableObjectBindingsStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[6, 23]], + code: durableObjectConfigCode + }, + { + path: 'src/routes/counter.ts', + language: 'ts', + focusLines: [[1, 13]], + code: durableObjectRouteCode + }, + { + path: 'src/transport.ts', + language: 'ts', + focusLines: [[1, 8]], + code: counterTransportCode + }, + { + path: 'src/lib/counter-value.ts', + language: 'ts', + focusLines: [[1, 10]], + code: counterValueCode + }, + { + path: 'src/do/counter.ts', + language: 'ts', + focusLines: [[1, 10]], + code: counterObjectCode + } + ] + } + ], + callouts: [ + { + tone: 'info', + title: 'Why this is a good first Durable Object', + body: [ + 'It proves binding lookup, object identity, route-to-object flow, and persisted state without turning the whole worker into object-specific plumbing.' + ] + } + ] + }, + { + id: 'r2-round-trip', + title: 'Add one R2-backed route', + description: 'Keep the same worker shape and let one route file own the bucket round-trip.', + paragraphs: [ + 'Here the route path becomes the obvious home for the binding: `src/routes/files/[name].ts` owns both the `PUT` and `GET` flow for one named object.', + 'The shared helper still provides request-wide context, route params, and request reads through runtime helpers, while the route file keeps the bucket usage visible and local to the URL that needs it.' + ], + snippets: [ + { + title: 'Same worker, now add one file route and one bucket binding', + description: + 'The global fetch file stays tiny. The new work lives in one route file under `src/routes/files/[name].ts`, while the helper module still reads the active request through runtime helpers.', + activeFile: 'src/routes/files/[name].ts', + structure: r2BindingsStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[6, 17]], + code: r2ConfigCode + }, + { + path: 'src/routes/files/[name].ts', + language: 'ts', + focusLines: [[1, 29]], + code: r2RouteCode + } + ] + } + ], + callouts: [ + { + tone: 'accent', + title: 'Why this is a good first R2 route', + body: [ + 'It proves route params, the bucket binding, and a clean read/write boundary without teaching a giant upload architecture before the first success.' + ] + } + ] + }, + { + id: 'browser-title-read', + title: 'Add one browser-backed route', + description: 'Keep the same worker shape and let one route prove the browser binding.', + paragraphs: [ + 'Browser Rendering gets simpler when it looks like the other examples: the shared fetch file stays untouched, and one route file owns the browser work.', + 'Install `@cloudflare/puppeteer` before you try this route, and remember that Devflare currently supports exactly one browser binding in config.' + ], + snippets: [ + { + title: 'Same worker, now add one browser-backed route', + description: + 'The route tree grows by one file, and the helper still gives that route access to request-scoped context without bloating `src/fetch.ts`.', + activeFile: 'src/routes/page-title.ts', + structure: browserBindingsStructure, + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[6, 17]], + code: browserConfigCode + }, + { + path: 'src/routes/page-title.ts', + language: 'ts', + focusLines: [[1, 16]], + code: browserRouteCode + } + ] + } + ], + callouts: [ + { + tone: 'warning', + title: 'Keep the first browser path skinny', + body: [ + 'One title read is enough to prove the binding. Save screenshots, PDFs, and longer browser workflows for the next pass once the launch path is already trustworthy.' + ] + } + ] + }, + { + id: 'next-pages', + title: 'Open the next page when the first quick win works', + description: + 'Once one tiny example works locally, jump to the dedicated binding guides for the bigger caveats, testing patterns, and architecture choices.', + cards: [ + { + label: 'Bindings', + title: 'Durable Objects guide', + body: 'Read the fuller guidance on stateful objects, migrations, previews, and local testing.', + href: docsLink('bindings/durable-objects') + }, + { + label: 'Bindings', + title: 'R2 guide', + body: 'Open the R2 page for delivery boundaries, testing patterns, and storage choices.', + href: docsLink('bindings/r2') + }, + { + label: 'Bindings', + title: 'Browser Rendering guide', + body: 'Open the browser guide when you need the single-binding caveat, dev-server details, or heavier browser workflows.', + href: docsLink('bindings/browser-rendering') + } + ] + } + ] + }, + { + slug: 'deploy-and-preview', + group: 'Quickstart', + navTitle: 'Deploy and Preview', + readTime: '4 min read', + eyebrow: 'Ship it', + title: 'Deploy one preview, then delete it cleanly', + summary: + 'Take the same starter worker, ship one named preview, then remove that preview scope cleanly.', + description: + 'The project tree does not need to become more complicated for the first deploy. Use the same small worker, one memorable preview name, and one equally explicit cleanup command.', + highlights: [ + 'Deploys are explicit: preview always uses `--preview `.', + 'Use one memorable scope name like `next` or `pr-123` and reuse it consistently.', + 'Deleting a preview should be just as explicit as creating it.', + 'Once the first preview works, move on to production and workflow docs.' + ], + facts: [ + { label: 'Best for', value: 'The first named preview deploy and cleanup loop' }, + { label: 'Preview command', value: '`bunx --bun devflare deploy --preview `' }, + { + label: 'Cleanup command', + value: '`bunx --bun devflare previews cleanup --scope --apply`' + } + ], + sourcePages: ['packages/devflare/src/cli/commands/deploy.ts', 'README.md'], + sections: [ + { + id: 'deploy-a-preview', + title: 'Deploy a named preview', + description: + 'Named previews are the easiest first deploy shape because the destination is obvious in the command itself and the same name can follow the preview through CI, cleanup, and review.', + paragraphs: [ + 'If the first worker runs locally and your first test already passed, the project is ready for a simple preview loop. You do not need a new framework layer or a bigger repo ritual first.', + 'Pick one preview name such as `next` or `pr-123`. Then deploy with `--preview ` so the preview target is visible in your shell history and logs.' + ], + steps: [ + 'Finish the worker or app locally and make sure `bunx --bun devflare dev` already works.', + 'Pick a preview scope name such as `next` or `pr-123`.', + 'Run the explicit preview deploy command.', + 'Open the preview and confirm the smallest important path works before you automate anything bigger.' + ], + snippets: [ + { + title: 'Preview-ready worker files before you deploy', + description: + 'Keep the preview example anchored in the same application files a teammate would review, not only the deploy command.', + activeFile: 'src/fetch.ts', + structure: [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'preview-command.sh', muted: true } + ], + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + code: String.raw`import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + name: 'orders-api', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + ORDERS_CACHE: pv('orders-cache') + } + } +})` + }, + { + path: 'src/fetch.ts', + language: 'ts', + code: String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function fetch(event: FetchEvent): Promise { + const url = new URL(event.request.url) + const orderId = url.pathname.split('/').at(-1) ?? 'latest' + const cacheKey = 'order:' + orderId + const cached = await event.env.ORDERS_CACHE.get(cacheKey) + + if (cached) { + return Response.json(JSON.parse(cached)) + } + + const order = { id: orderId, status: 'ready-for-preview' } + await event.env.ORDERS_CACHE.put(cacheKey, JSON.stringify(order), { expirationTtl: 300 }) + + return Response.json(order) +}` + } + ] + }, + { + title: 'Deploy the same starter worker as a named preview', + description: + 'The active file is just the command transcript. The project tree is still the same small worker from the earlier quickstart pages.', + filename: 'preview-command.sh', + language: 'bash', + structure: [ + { path: 'devflare.config.ts', muted: true }, + { path: 'src', kind: 'folder', muted: true }, + { path: 'src/fetch.ts', muted: true }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true }, + { path: 'preview-command.sh' } + ], + code: String.raw`bunx --bun devflare build --env preview +bunx --bun devflare deploy --preview next` + } + ], + callouts: [ + { + tone: 'success', + title: 'Explicit is the point', + body: [ + 'If the command says `--preview next`, you already know where it is going. That clarity is the whole reason the CLI insists on explicit deploy targets.' + ] + } + ] + }, + { + id: 'delete-the-preview', + title: 'Delete the preview when it is done teaching you something', + description: + 'Preview cleanup should use the same scope name you deployed with. That keeps teardown reviewable and stops preview-only resources from lingering just because nobody remembers the exact branch name later.', + paragraphs: [ + 'If the preview owns preview-only resources, `cleanup` is the quickest way to remove them. Use the exact same scope string you deployed with so the target stays unmistakable.', + 'If you later need richer lifecycle management, the dedicated preview operations docs cover scope inspection, cleanup planning, and broader cleanup runs. For the first loop, resource cleanup is enough to understand the shape.' + ], + snippets: [ + { + title: 'Clean up the same named preview', + description: + 'The cleanup command should feel like the mirror image of the deploy command: same project, same scope name, same explicit target.', + filename: 'cleanup-preview.sh', + language: 'bash', + structure: [ + { path: 'devflare.config.ts', muted: true }, + { path: 'src', kind: 'folder', muted: true }, + { path: 'src/fetch.ts', muted: true }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true }, + { path: 'cleanup-preview.sh' } + ], + code: String.raw`bunx --bun devflare previews cleanup --scope next --apply` + } + ], + bullets: [ + 'Reuse the same preview scope name you deployed with.', + 'Keep cleanup commands explicit so logs clearly show what is being removed.', + 'If the preview becomes a real recurring workflow, move that command into CI instead of relying on team memory.' + ], + callouts: [ + { + tone: 'warning', + title: 'Delete previews explicitly too', + body: [ + 'Preview environments get messy when deploys are automated but cleanup rules live only in peopleโ€™s heads. Use the same explicit naming discipline for teardown that you used for deploy.' + ] + } + ] + }, + { + id: 'what-to-read-next', + title: 'What to read next', + description: + 'Once the first preview loop works, jump to production deploy rules and GitHub automation.', + paragraphs: [ + 'When this local preview loop is ready to leave your shell history and become reviewable automation, continue with `github-workflows`. That page maps the exact `.github/workflows/*.yml` files this repo uses for PR comments, branch previews, production deploys, and cleanup.' + ], + cards: [ + { + label: 'Ship & operate', + title: 'Production deploys', + body: 'Read the production guide for explicit targets, preflight checks, and deploy inspection habits.', + href: docsLink('production-deploys') + }, + { + label: 'Ship & operate', + title: 'GitHub workflows', + body: 'Continue with the repo-backed workflow guide when you want this preview loop to become PR comments, branch previews, production deploys, and cleanup jobs under `.github/workflows`.', + href: docsLink('github-workflows') + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/start-here/part-3.ts b/apps/documentation/src/lib/docs/content/start-here/part-3.ts new file mode 100644 index 0000000..129d8d9 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/part-3.ts @@ -0,0 +1,714 @@ +import type { DocPage } from '../../types' +import { + browserBindingsStructure, + browserConfigCode, + browserRouteCode, + counterObjectCode, + counterTransportCode, + counterValueCode, + docsLink, + durableObjectBindingsStructure, + durableObjectConfigCode, + durableObjectRouteCode, + firstWorkerConfigCode, + firstWorkerFetchCode, + firstWorkerStructure, + firstWorkerTestCode, + r2BindingsStructure, + r2ConfigCode, + r2RouteCode, + requestContextHelperCode, + routedWorkerConfigCode, + routedWorkerFetchCode, + routedWorkerIndexRouteCode, + routedWorkerStructure, + supportCoverageTooltips +} from './shared' + +export const startHereDocsPart3: DocPage[] = [ + { + slug: 'runtime-context', + group: 'Devflare', + navTitle: 'Runtime context', + readTime: '8 min read', + eyebrow: 'Runtime helpers', + title: 'Use runtime helpers without passing the event through every function', + summary: + 'Use explicit event parameters at handler boundaries, then use `getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, and `locals` inside helper code that runs during the same request or job.', + description: + 'The everyday rule is simple: accept the event in the handler, pass explicit data where it is clearer, and use runtime helpers when nested helper code needs the active request, env, context, event, or request-scoped `locals`.', + highlights: [ + 'If you came here because of `getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, or `locals`, you are in the right place.', + 'Prefer explicit event parameters at the handler boundary and getters inside helpers called by that handler.', + '`env`, `ctx`, and `event` from `devflare/runtime` are readonly proxies, while `locals` is mutable request-scoped storage.', + 'Per-surface getters such as `getFetchEvent()` and `getQueueEvent()` also expose `.safe()` for nullable access.', + 'Open runtime context internals only when you are debugging helper setup or changing runtime infrastructure.' + ], + facts: [ + { + label: 'Main rule', + value: 'Event at the boundary, helpers inside the same handler trail' + }, + { + label: 'Main helpers', + value: + '`getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, and `locals`' + }, + { + label: 'Stored shape', + value: '`env`, `ctx`, `request`, `event`, and `locals` while a handler is active' + }, + { label: 'Mutable lane', value: '`locals` / `event.locals`' }, + { + label: 'Failure mode', + value: 'Strict runtime helpers throw outside an active handler trail' + } + ], + sourcePages: [ + 'packages/devflare/README.md', + 'context.ts', + 'context-events.ts', + 'context-types.ts', + 'exports.ts', + 'validation.ts', + 'context.test.ts', + 'exports.test.ts', + 'validation.test.ts', + 'worker-only-multi-surface-events.test.ts', + 'event-accessors.test.ts' + ], + sections: [ + { + id: 'helper-map', + title: 'Pick the helper that matches where your code is running', + paragraphs: [ + 'If `getFetchEvent()` or `env.DB` works in one helper and fails in another, first check whether that code still runs during the active request, job, or Durable Object call.', + 'Use per-surface getters when the helper needs the current event, use `env` or `ctx` when a helper only needs active bindings or execution context, and use `locals` for request-scoped data shared across middleware and helper calls.' + ], + table: { + headers: ['Helper family', 'Examples', 'Use it for'], + rows: [ + [ + 'Per-surface getters', + '`getFetchEvent()`, `getQueueEvent()`, `getScheduledEvent()`, `getEmailEvent()`, `getTailEvent()`', + 'Return the current rich event after verifying the active surface type; `.safe()` returns `null` instead of throwing.' + ], + [ + 'Generic context getter', + '`getContext()`', + 'Returns the active stored context shape when one exists and throws when code is running outside an active handler trail.' + ], + [ + 'Readonly runtime proxies', + '`env`, `ctx`, `event`', + 'Read the active environment bindings, execution context, or current event without threading parameters through every helper.' + ], + [ + 'Mutable runtime proxy', + '`locals`', + 'Reads and writes the per-request or per-job mutable storage object attached to the active context.' + ] + ] + }, + callouts: [ + { + tone: 'accent', + title: 'A practical reading guide', + body: [ + 'If the question in your head is โ€œwhen can I safely call `getFetchEvent()` or read `env` without passing the event around?โ€, the rest of this page is answering exactly that.' + ] + } + ] + }, + { + id: 'event-first', + title: 'Start with event-first handlers and let helpers discover the active event later', + paragraphs: [ + 'Event-first handlers keep runtime state explicit at the boundary and still let nested helpers recover the current event later when plumbing it through every function call would be pure ceremony. That is the everyday job for helpers like `getFetchEvent()` and `locals`.', + 'In normal application code you should not need to establish runtime context manually. Devflare already does that for generated worker entrypoints, middleware, route dispatch, Durable Object wrappers, the dev server, and the built-in test helpers.' + ], + snippets: [ + { + title: 'Use the explicit event at the boundary and a getter inside the helper', + description: + 'This keeps the handler honest while still letting helper code read the active request and shared locals later in the same call trail.', + activeFile: 'src/fetch.ts', + structure: [ + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/lib', kind: 'folder' }, + { path: 'src/lib/current-path.ts' } + ], + files: [ + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[1, 11]], + code: String.raw`import { locals, type FetchEvent } from 'devflare/runtime' +import { currentPath } from './lib/current-path' + +export async function fetch(event: FetchEvent): Promise { + event.locals.requestId = crypto.randomUUID() + + return Response.json({ + path: currentPath(), + method: event.request.method, + requestId: String(locals.requestId) + }) +}` + }, + { + path: 'src/lib/current-path.ts', + language: 'ts', + focusLines: [[1, 4]], + code: String.raw`import { getFetchEvent } from 'devflare/runtime' + +export function currentPath(): string { + return getFetchEvent().url.pathname +}` + } + ] + } + ] + }, + { + id: 'runtime-context-internals-link', + title: 'Open internals only when helper setup is the problem', + paragraphs: [ + 'The normal runtime-context page should help you write application code. Open the internals page only when you are changing runtime infrastructure, debugging helper setup, or checking how Devflare creates the active context.' + ], + cards: [ + { + href: docsLink('runtime-context-internals'), + label: 'Internals', + meta: 'Runtime context', + title: 'Runtime context internals', + body: 'Read the stored context shape, setup steps, and advanced helper details.' + } + ] + }, + { + id: 'access-order', + title: 'Getters and proxies are just different ways of reading the same store', + table: { + headers: ['API', 'What it reads', 'Failure behavior', 'Mutation'], + rows: [ + [ + 'Handler parameters', + 'The explicit event object Devflare passes to the handler boundary.', + 'No lookup needed at the boundary.', + '`event.locals` is mutable.' + ], + [ + 'Per-surface getters like `getFetchEvent()`', + 'The stored `context.event` after Devflare verifies the active surface type.', + 'Throws `ContextUnavailableError`, while `.safe()` returns `null`.', + 'Readonly event view.' + ], + [ + '`getContext()`', + 'The full active `RequestContext` object for the current handler trail.', + 'Throws `ContextUnavailableError` outside an active handler trail.', + 'Use this mostly for debugging or advanced infrastructure helpers.' + ], + [ + '`env`, `ctx`, `event` proxies', + '`getContextOrNull()` through readonly proxy wrappers.', + 'Property access throws `ContextAccessError` outside an active handler trail.', + 'Readonly.' + ], + [ + '`locals` proxy', + '`getContextOrNull()?.locals` through the mutable context proxy.', + 'Property access throws `ContextAccessError` outside an active handler trail.', + 'Mutable and shared with `event.locals`.' + ] + ] + }, + paragraphs: [ + 'Pass the event explicitly at the top of the stack. Reach for getters or proxies only when helper code is still running in the same handler trail and threading that event downward would make the code noisier than the value it adds.', + 'This is also why strict runtime helpers throwing outside context is healthy: it stops top-level module code and random utility calls from pretending they are running inside a request when they are not.' + ], + callouts: [ + { + tone: 'accent', + title: 'A simple rule', + body: [ + 'Use explicit handler parameters first, getters second, proxies third, and mutable `locals` only for data that truly belongs to the current request or job.' + ] + } + ] + }, + { + id: 'surface-coverage', + title: 'Runtime helpers cover more than fetch', + table: { + headers: ['Surface', 'Event shape', 'Getter'], + rows: [ + ['HTTP worker', '`FetchEvent`', '`getFetchEvent()`'], + ['Queue consumer', '`QueueEvent`', '`getQueueEvent()`'], + ['Scheduled handler', '`ScheduledEvent`', '`getScheduledEvent()`'], + ['Inbound email', '`EmailEvent`', '`getEmailEvent()`'], + ['Tail handler', '`TailEvent`', '`getTailEvent()`'], + ['Durable Object fetch', '`DurableObjectFetchEvent`', '`getDurableObjectFetchEvent()`'], + ['Durable Object alarm', '`DurableObjectAlarmEvent`', '`getDurableObjectAlarmEvent()`'], + [ + 'Durable Object WebSocket message / close / error', + 'Dedicated WebSocket event types', + '`getDurableObjectWebSocketMessageEvent()`, `getDurableObjectWebSocketCloseEvent()`, `getDurableObjectWebSocketErrorEvent()`' + ], + ['Any Durable Object surface', '`DurableObjectEvent`', '`getDurableObjectEvent()`'] + ] + }, + paragraphs: [ + 'Worker surfaces expose `event.ctx` as the current `ExecutionContext`. Durable Object surfaces expose `event.ctx` as the current `DurableObjectState`, and Devflare also aliases that same value as `event.state` for clarity.', + 'For fetch and Durable Object fetch, Devflare augments the actual `Request` instance. For queue, scheduled, email, tail, and Durable Object WebSocket surfaces, it augments the native carrier object instead of replacing it with a fantasy wrapper.', + 'Three general-purpose utilities round out the API: `hasContext()` checks whether a context is active, `getEventContext()` returns the current event regardless of surface type, and `getEventContextOrNull()` does the same but returns `null` outside a context.' + ] + }, + { + id: 'locals-model', + title: '`locals` is the mutable storage lane, and it is isolated per context', + paragraphs: [ + 'Use `locals` for auth state, derived request data, request ids, or other values that belong to the current request or job and should be shared across middleware or helper layers.', + 'Within one handler trail, `locals` and `event.locals` point at the same underlying object. Across requests and jobs, each context gets a fresh locals object so state does not bleed between invocations.' + ], + snippets: [ + { + title: 'Write to `event.locals`, read from `locals` later in the same trail', + filename: 'src/fetch.ts', + language: 'ts', + code: String.raw`import { locals, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('x-request-id', String(locals.requestId)) + return next +} + +export const handle = sequence(requestId)` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Mutate `locals`, not the readonly proxies', + body: [ + '`env`, `ctx`, and `event` are readonly runtime views. If you need shared mutable state, put it on `locals` instead of trying to assign back into the underlying context objects.' + ] + } + ] + }, + { + id: 'when-context-is-missing', + title: 'Context is not available everywhere, and that is intentional', + bullets: [ + 'Module top-level code runs at cold start, not inside a request or job, so strict runtime helpers are unavailable there.', + 'Callbacks that run after the handler trail ends should take explicit inputs instead of assuming context is still alive.', + 'Timer callbacks like `setTimeout()` and `setInterval()` are outside the normal Devflare-managed handler trail.', + 'Per-surface getters and `getContext()` throw `ContextUnavailableError`, while proxy property access such as `env.DB` or `locals.userId` throws `ContextAccessError` naming the missing property.', + 'If you are unsure whether the matching surface is active, prefer `.safe()` accessors such as `getFetchEvent.safe()` over catching thrown errors.', + 'If runtime context access fails unexpectedly while bypassing Devflare-generated config or harnesses, open the runtime context internals page and verify the Worker still includes the compatibility flags Devflare normally adds for you.' + ], + callouts: [ + { + tone: 'info', + title: 'The fix is usually simpler than the error feels', + body: [ + 'Move the context access inside the handler, middleware, or helper that is called from that handler trail. If there is no active trail, take explicit inputs instead of hoping context exists.' + ] + } + ] + } + ] + }, + { + slug: 'runtime-context-internals', + group: 'Devflare', + navTitle: 'Runtime internals', + sidebarHidden: true, + readTime: '4 min read', + eyebrow: 'Runtime internals', + title: 'How Devflare establishes runtime context', + summary: + 'This page keeps the AsyncLocalStorage mechanics out of the normal usage guide while preserving them for maintainers and advanced debugging.', + description: + 'Use this page when helpers work in one runtime lane but not another, when you are changing runtime infrastructure, or when you need to verify exactly what Devflare stores while a handler is active.', + highlights: [ + 'Devflare stores a full request or job context while user code runs.', + 'Generated entrypoints, middleware, routes, Durable Object wrappers, the dev server, and test helpers use the same setup model.', + '`runWithEventContext()` and `runWithContext()` are infrastructure helpers, not the normal application API.' + ], + facts: [ + { label: 'Audience', value: 'Maintainers and advanced runtime debugging' }, + { label: 'Normal app page', value: '`runtime-context`' }, + { label: 'Core primitive', value: '`AsyncLocalStorage`' } + ], + sourcePages: [ + 'packages/devflare/README.md', + 'context.ts', + 'context-events.ts', + 'context-types.ts', + 'exports.ts', + 'validation.ts', + 'context.test.ts', + 'exports.test.ts', + 'validation.test.ts', + 'worker-only-multi-surface-events.test.ts', + 'event-accessors.test.ts' + ], + sections: [ + { + id: 'what-gets-stored', + title: 'What Devflare stores while a handler is active', + paragraphs: [ + 'Devflare creates `AsyncLocalStorage()` and stores more than the current request. The context includes environment bindings, execution context or Durable Object state, optional request, mutable locals, runtime surface type, and the original event object.', + 'That is why the higher-level runtime APIs can stay small. Per-surface getters return the stored event when the active surface matches, and the runtime proxies read the same context without forcing every helper to receive the event manually.' + ], + snippets: [ + { + title: 'Simplified shape of the stored runtime context', + filename: 'src/runtime/context.ts', + language: 'ts', + code: String.raw`type RequestContext = { + env: TEnv + ctx: ExecutionContext | DurableObjectState | null + request: Request | null + locals: Record + type: RuntimeEventType + event: EventContext +}` + } + ], + callouts: [ + { + tone: 'info', + title: 'The original event object is preserved', + body: [ + 'Devflare does not discard the richer surface event after extracting a request or context. The original event stays on `context.event`, which is what the per-surface getters read later.' + ] + } + ] + }, + { + id: 'how-devflare-establishes-context', + title: 'How Devflare creates and installs the context', + paragraphs: [ + 'For fetch, queue, scheduled, email, tail, and Durable Object surfaces, Devflare first creates a rich event object using helpers such as `createFetchEvent()`, `createQueueEvent()`, or the Durable Object event builders. It then builds a `RequestContext` from that event and runs the handler trail inside `storage.run(...)`.', + 'The same mechanism is reused by generated worker entrypoints, request-wide middleware, route resolution, Durable Object wrappers, the dev server, and `createTestContext()` helpers such as `cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, and `cf.tail`.' + ], + steps: [ + 'Devflare builds the rich event object for the active surface.', + 'It creates a `RequestContext` from `event.env`, `event.ctx`, `event.request ?? null`, `event.locals`, `event.type`, and the original event object.', + 'It runs middleware, route resolution, or the surface handler inside `AsyncLocalStorage` with that context.', + 'Helpers call getters or proxies, which read the current store instead of receiving the event manually.', + 'When the handler trail ends, strict runtime helpers stop exposing context.' + ], + snippets: [ + { + title: 'The important part of `runWithEventContext()` is intentionally small', + filename: 'src/runtime/context.ts', + language: 'ts', + code: String.raw`const context = { + env: event.env, + ctx: event.ctx, + request: event.request ?? null, + locals: event.locals, + type: event.type, + event +} + +return storage.run(context, fn)` + } + ] + }, + { + id: 'advanced-helpers', + title: '`runWithEventContext()` and `runWithContext()` are infrastructure helpers', + paragraphs: [ + 'By the time you are considering these helpers, the normal app-facing story should already be working: handlers, middleware, generated entrypoints, and `createTestContext()` establish context for you. These APIs exist for runtime and test infrastructure that must preserve or synthesize that context deliberately.', + '`runWithEventContext(event, fn)` preserves an existing rich event object. `runWithContext(env, ctx, request, fn, type)` is the lower-level compatibility helper: it creates fresh locals, synthesizes a default event with `createDefaultEvent()`, and then stores that event before running your function.' + ], + snippets: [ + { + title: 'Wrap one infrastructure assertion with an existing event', + filename: 'src/test/runtime-context.ts', + language: 'ts', + code: String.raw`import { getFetchEvent, runWithEventContext, type FetchEvent } from 'devflare/runtime' + +export async function readPathInsideContext(event: FetchEvent): Promise { + return runWithEventContext(event, async () => { + return getFetchEvent().url.pathname + }) +}` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Do not reach for the escape hatch by habit', + body: [ + 'If you are writing app code instead of runtime or test infrastructure, pass the event into your handler and let Devflare establish the context automatically.' + ] + } + ] + } + ] + }, + { + slug: 'http-routing', + group: 'Devflare', + navTitle: 'Routing', + readTime: '6 min read', + eyebrow: 'HTTP layer', + title: 'Split request-wide middleware from route leaves so HTTP stays easy to read', + summary: + 'Use `src/fetch.ts` for request-wide behavior, `src/routes/**` for leaf handlers, and `files.routes` when you need a custom root, prefix, or route-only app.', + description: + 'Devflare gives you a request-wide fetch entry and a built-in file router. The safest mental model is simple: keep broad middleware in `src/fetch.ts`, keep URL-specific behavior in `src/routes/**`, and reach for `files.routes` when the route tree needs custom mounting rules.', + highlights: [ + '`src/fetch.ts` is for whole-app middleware, not leaf business logic.', + '`src/routes/**` can be auto-discovered, remapped with `files.routes`, or disabled with `files.routes: false`.', + 'Same-module method handlers in `src/fetch.ts` take precedence before the matched route file runs.', + '`files.routes` is app routing config, not Cloudflare deployment `routes`.' + ], + facts: [ + { + label: 'Best for', + value: 'HTTP apps that need middleware, route params, or a mounted route tree' + }, + { + label: 'Primary order', + value: '`src/fetch.ts` โ†’ same-module methods โ†’ matched route file' + }, + { label: 'Route config', value: '`files.routes`' } + ], + sourcePages: [ + 'README.md', + 'packages/devflare/README.md', + 'packages/devflare/src/config/schema.ts', + 'packages/devflare/src/test/simple-context.ts' + ], + sections: [ + { + id: 'two-layers', + title: 'Two HTTP layers by design', + cards: [ + { + title: '`src/fetch.ts`', + body: 'Use it for request-wide behavior that should apply before or after the final leaf handler runs.' + }, + { + title: '`src/routes/**`', + body: 'Use it for specific URL handlers so the file tree mirrors the URLs you serve.' + } + ], + paragraphs: [ + 'If `src/fetch.ts` exports `fetch` or `handle`, that module becomes the primary HTTP entry. Inside `resolve(event)`, Devflare checks same-module method handlers first and then dispatches to the matched route file when needed.', + 'That ordering is what lets middleware stay global while route files remain the clean leaf-handler story.' + ], + steps: [ + 'Devflare enters through `src/fetch.ts` when that file exports `fetch` or `handle`.', + 'Inside `resolve(event)`, exact same-module HTTP method handlers such as `GET` or `POST` are checked first, `HEAD` falls back to `GET` with an empty body, and `ALL` is the last module-local fallback.', + 'If no same-module method handler answers the request, Devflare falls through to the matched route file.', + 'Devflare computes route params before request-wide middleware continues, so `event.params` is available to both outer middleware and the leaf handler.' + ] + }, + { + id: 'middleware-pattern', + title: 'Use middleware for broad concerns, not leaf business logic', + snippets: [ + { + title: 'Keep the middleware file and the leaf route side by side', + description: + 'The global file owns request-wide behavior. The route file owns one URL. When those stay separate, the whole HTTP layer stays readable.', + activeFile: 'src/fetch.ts', + structure: [ + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/users', kind: 'folder' }, + { path: 'src/routes/users/[id].ts' } + ], + files: [ + { + path: 'src/fetch.ts', + language: 'ts', + focusLines: [[4, 18]], + code: String.raw`import { sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' + +async function cors(event: FetchEvent, resolve: ResolveFetch): Promise { + if (event.request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + }) + } + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('Access-Control-Allow-Origin', '*') + return next +} + +export const handle = sequence(cors)` + }, + { + path: 'src/routes/users/[id].ts', + language: 'ts', + focusLines: [[2, 5]], + code: String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function GET(event: FetchEvent): Promise { + return Response.json({ id: event.params.id }) +}` + } + ] + } + ], + callouts: [ + { + tone: 'warning', + title: 'Keep the split clean', + body: [ + 'If a piece of logic only matters for one URL, it probably belongs in a route file, not in global middleware.' + ] + } + ] + }, + { + id: 'route-only-apps', + title: 'Route-only apps are valid when you do not need global middleware', + paragraphs: [ + 'You do not need `src/fetch.ts` just to use the file router. If every concern is leaf-local, a route tree on its own is a clean supported shape.', + 'That is especially useful for small APIs where a mounted route prefix matters more than request-wide middleware.' + ], + snippets: [ + { + title: 'Mount a route tree under `/api` without a `src/fetch.ts` file', + description: + 'Explicit `files.routes` keeps the route root and prefix obvious in code review while the app stays route-only.', + activeFile: 'devflare.config.ts', + structure: [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/users', kind: 'folder' }, + { path: 'src/routes/users/[id].ts' } + ], + files: [ + { + path: 'devflare.config.ts', + language: 'ts', + focusLines: [[3, 9]], + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'users-api', + files: { + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +})` + }, + { + path: 'src/routes/users/[id].ts', + language: 'ts', + focusLines: [[1, 5]], + code: String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ params }: FetchEvent): Promise { + return Response.json({ id: params.id }) +}` + } + ] + } + ], + callouts: [ + { + tone: 'info', + title: 'Start route-only when the app really is route-only', + body: [ + 'Skip `src/fetch.ts` until you genuinely need request-wide auth, logging, CORS, or response shaping. Add the global file later; the route tree stays valid.' + ] + } + ] + }, + { + id: 'route-config', + title: 'Use `files.routes` to remap, prefix, or disable the route tree', + table: { + headers: ['Shape', 'What it does'], + rows: [ + ['Omit `files.routes`', '`src/routes` is auto-discovered when that directory exists.'], + [ + "`{ dir: 'app-routes' }`", + 'Changes the route root without changing the rest of the routing model.' + ], + [ + "`{ dir: 'src/routes', prefix: '/api' }`", + 'Mounts discovered routes under a fixed prefix such as `/api`.' + ], + ['`false`', 'Disables file-route discovery entirely.'] + ] + }, + paragraphs: [ + '`files.routes` is app routing config. It controls how Devflare discovers and mounts route modules inside the Worker package.', + 'It is not the same thing as top-level Cloudflare deployment `routes`, which decide which hostnames and path patterns reach the Worker in the first place.' + ], + callouts: [ + { + tone: 'warning', + title: 'Do not blur app routing and deployment routing', + body: [ + 'If you are choosing files inside your Worker, you want `files.routes`. If you are deciding which traffic reaches the Worker at all, you want top-level Cloudflare `routes`.' + ] + } + ] + }, + { + id: 'route-semantics', + title: 'Specificity and guardrails matter once the tree grows', + table: { + headers: ['Filename', 'Meaning'], + rows: [ + ['`src/routes/index.ts`', 'Matches `/`.'], + ['`src/routes/users/[id].ts`', 'Matches `/users/:id` and exposes `event.params.id`.'], + [ + '`src/routes/blog/[...slug].ts`', + 'Matches one-or-more trailing segments and exposes `slug` as joined path text.' + ], + [ + '`src/routes/docs/[[...slug]].ts`', + 'Matches both the directory root and deeper optional rest paths.' + ] + ] + }, + bullets: [ + 'Static routes beat dynamic routes, dynamic routes beat rest routes, and optional rest routes are checked last.', + '`src/routes/users/[id].ts` and `src/routes/users/[slug].ts` normalize to the same pattern and are rejected as conflicts.', + 'Files or directories beginning with `_` are ignored so route-local helpers can live beside handlers.', + '`HEAD` falls back to `GET` if you do not export a dedicated `HEAD` handler.', + 'Route modules can use HTTP method exports, or a primary `fetch` / `handle` export, just like the fetch module.' + ], + callouts: [ + { + tone: 'accent', + title: 'Conflict errors are a feature, not a nuisance', + body: [ + 'If two files normalize to the same route pattern, Devflare rejects the tree instead of guessing. That makes route review boring in the best possible way.' + ] + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/start-here/part-4.ts b/apps/documentation/src/lib/docs/content/start-here/part-4.ts new file mode 100644 index 0000000..e0aab7b --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/part-4.ts @@ -0,0 +1,141 @@ +import type { DocPage } from '../../types' +import { + browserBindingsStructure, + browserConfigCode, + browserRouteCode, + counterObjectCode, + counterTransportCode, + counterValueCode, + docsLink, + durableObjectBindingsStructure, + durableObjectConfigCode, + durableObjectRouteCode, + firstWorkerConfigCode, + firstWorkerFetchCode, + firstWorkerStructure, + firstWorkerTestCode, + r2BindingsStructure, + r2ConfigCode, + r2RouteCode, + requestContextHelperCode, + routedWorkerConfigCode, + routedWorkerFetchCode, + routedWorkerIndexRouteCode, + routedWorkerStructure, + supportCoverageTooltips +} from './shared' + +export const startHereDocsPart4: DocPage[] = [ + { + slug: 'config-basics', + group: 'Devflare', + navTitle: 'Config basics', + readTime: '5 min read', + eyebrow: 'Configuration', + title: 'Author stable config, keep secrets and generated output in their own lanes', + summary: + 'Write `devflare.config.ts` for humans first, let Devflare merge environments and resolve names later, and treat generated Wrangler-facing files as outputs rather than authoring surfaces.', + description: + 'The easiest way to keep Devflare predictable is to keep stable intent in authored config and let build or deploy flows resolve the noisy details. That applies to environment overlays, stable resource names, secrets, and generated output.', + highlights: [ + '`config.env` is a Devflare merge layer, not just a raw Wrangler environment mirror.', + 'Use stable names for resources when you can, and let id resolution happen later.', + '`vars` are string config; `secrets` declare runtime expectations, and the schema accepts `{ required: false }` even though generated env typing still treats declared secrets as present today.', + 'Use `wrangler.passthrough` as the escape hatch for unsupported Wrangler keys, and treat it as a deliberate top-level override rather than a second config language.' + ], + facts: [ + { label: 'Best for', value: 'Anyone authoring or reviewing `devflare.config.ts`' }, + { label: 'Source of truth', value: 'Authored config plus source files' }, + { label: 'Escape hatch', value: '`wrangler.passthrough`' } + ], + sourcePages: ['packages/devflare/src/config/schema.ts', 'README.md'], + sections: [ + { + id: 'flow', + title: 'A simple config flow', + steps: [ + 'Author stable intent in `devflare.config.ts`.', + 'Optionally merge a named Devflare environment with `--env `.', + 'Resolve account ids or resource ids only in flows that truly need them.', + 'Emit Wrangler-compatible output as generated artifacts.', + 'Build or deploy from generated output without hand-editing it.' + ], + callouts: [ + { + tone: 'info', + title: 'If a generated file feels hand-maintained, move the intent back up', + body: [ + 'That usually means the authored config is missing a real source-of-truth value or needs a passthrough key.' + ] + } + ] + }, + { + id: 'vars-secrets', + title: 'Keep vars, secrets, and `.env` separate', + table: { + headers: ['Layer', 'Use it for'], + rows: [ + ['`vars`', 'String config that compiles into generated Wrangler output.'], + [ + '`secrets`', + 'Declaring which runtime secret bindings should exist. The schema accepts `{ required: false }`, but generated env typing still treats declared secrets as present either way today.' + ], + ['`.env`', 'Inputs used while evaluating `devflare.config.*` at config time.'], + ['`.env.example`', 'Documenting config-time variables for the team.'] + ] + }, + paragraphs: [ + 'Devflare prefers a workspace-root `.env` when it finds a workspace ancestor; otherwise it falls back to the nearest ancestor `.env` before evaluating config. That is useful for config-time values, but it does not make `.dev.vars*` the source of truth for worker-only dev or tests.', + 'Stable infrastructure names belong in authored config. Do not hide them in secrets just because another tool happens to like environment variables.' + ] + }, + { + id: 'generated-output', + title: 'Generated artifacts are outputs, not contracts', + bullets: [ + '`.devflare/wrangler.jsonc`', + '`.devflare/build/wrangler.jsonc`', + '`.devflare/worker-entrypoints/main.ts` and `.js` when Devflare needs wrapper glue around the worker surfaces it discovered', + '`.devflare/vite.config.mjs`', + '`.wrangler/deploy/config.json`', + '`env.d.ts`' + ], + paragraphs: [ + '`wrangler.passthrough` is a shallow top-level override. Use it when Devflare does not model a Wrangler key yet, not as a place to mirror the whole generated config by habit.', + 'Devflare only generates `.devflare/worker-entrypoints/main.ts` when it needs to wrap or compose the worker surfaces it discovered. If `wrangler.passthrough.main` is set, or the fetch worker already lives at `assets.directory/_worker.js`, Devflare can skip that generated main entry and use the explicit worker instead.' + ], + snippets: [ + { + title: 'Use passthrough for unsupported Wrangler keys', + language: 'ts', + code: String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'advanced-worker', + files: { + fetch: 'src/fetch.ts' + }, + wrangler: { + passthrough: { + placement: { + mode: 'smart' + } + } + } +})` + } + ], + callouts: [ + { + tone: 'warning', + title: 'Passthrough is an explicit escape hatch', + body: [ + 'It wins on top-level key conflicts, so use it deliberately instead of turning it into a second config language.' + ] + } + ] + } + ] + } +] diff --git a/apps/documentation/src/lib/docs/content/start-here/shared.ts b/apps/documentation/src/lib/docs/content/start-here/shared.ts new file mode 100644 index 0000000..1c60090 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/shared.ts @@ -0,0 +1,333 @@ +import type { DocCodeTreeEntry, DocPage } from '../../types' + +export const docsLink = (slug: string): string => `/docs/${slug}` + +export const supportCoverageTooltips = { + Full: 'Full โ€” Devflare covers config, local runtime, testing, docs, and the everyday workflow for this surface.', + Remote: + 'Remote โ€” the surface works with Cloudflare, but full fidelity requires remote Cloudflare infrastructure or platform behavior.', + Limited: + 'Limited โ€” there is a real supported lane, but the contract is intentionally narrower today.', + None: 'None โ€” Devflare does not model that surface yet, so reach for raw Cloudflare tooling or Wrangler passthrough instead.' +} as const + +export const firstWorkerConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +})` + +export const firstWorkerFetchCode = String.raw`import type { FetchEvent } from 'devflare/runtime' + +export async function fetch({ url }: FetchEvent): Promise { + return new Response(url.pathname === '/' ? 'Hello from Devflare' : url.pathname) +}` + +export const firstWorkerTestCode = String.raw`import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +describe('hello-worker', () => { + test('GET / returns text', async () => { + const response = await cf.worker.get('/') + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello from Devflare') + }) +})` + +export const firstWorkerStructure: DocCodeTreeEntry[] = [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'env.d.ts', muted: true } +] + +export const routedWorkerConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + } +})` + +export const routedWorkerFetchCode = String.raw`import { sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' +import { rememberRequest } from '../lib/request-context' + +async function requestContext(event: FetchEvent, resolve: ResolveFetch): Promise { + rememberRequest() + return resolve(event) +} + +export const handle = sequence(requestContext)` + +export const requestContextHelperCode = String.raw`import { getFetchEvent, locals } from 'devflare/runtime' + +export function rememberRequest(): void { + locals.requestId = crypto.randomUUID() +} + +export function activeRequestPath(): string { + return getFetchEvent().url.pathname +} + +export function activeRequestId(): string { + return String(locals.requestId) +} + +export function activeRouteParam(name: string): string { + return getFetchEvent().params[name] +} + +export async function activeRequestText(): Promise { + return getFetchEvent().request.text() +}` + +export const routedWorkerIndexRouteCode = String.raw`import { activeRequestId, activeRequestPath } from '../lib/request-context' + +export async function GET(): Promise { + return Response.json({ + message: 'Hello from Devflare', + path: activeRequestPath(), + requestId: activeRequestId() + }) +}` + +export const routedWorkerStructure: DocCodeTreeEntry[] = [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts' }, + { path: 'src/lib', kind: 'folder' }, + { path: 'src/lib/request-context.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/index.ts' }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true } +] + +export const durableObjectConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + }, + transport: 'src/transport.ts', + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +})` + +export const durableObjectRouteCode = String.raw`import { env } from 'devflare/runtime' +import { activeRequestId, activeRequestPath } from '../lib/request-context' + +export async function GET(): Promise { + const id = env.COUNTER.idFromName('global') + const counter = env.COUNTER.get(id) + const count = await counter.increment() + + return Response.json({ + count: count.value, + double: count.double, + path: activeRequestPath(), + requestId: activeRequestId() + }) +}` + +export const counterObjectCode = [ + "import { DurableObject } from 'cloudflare:workers'", + "import { CounterValue } from '../lib/counter-value'", + '', + 'export class Counter extends ' + 'DurableObject {', + '\tasync increment(amount: number = 1): Promise {', + "\t\tconst count = Number((await this.ctx.storage.get('count')) ?? 0) + amount", + "\t\tawait this.ctx.storage.put('count', count)", + '\t\treturn new CounterValue(count)', + '\t}', + '}' +].join('\n') + +export const counterValueCode = String.raw`export class CounterValue { + value: number + + constructor(value: number) { + this.value = value + } + + get double(): number { + return this.value * 2 + } +}` + +export const counterTransportCode = String.raw`import { CounterValue } from '../lib/counter-value' + +export const transport = { + CounterValue: { + encode: (value: unknown) => + value instanceof CounterValue ? value.value : false, + decode: (value: number) => new CounterValue(value) + } +}` + +export const durableObjectBindingsStructure: DocCodeTreeEntry[] = [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts', muted: true }, + { path: 'src/lib', kind: 'folder' }, + { path: 'src/lib/counter-value.ts' }, + { path: 'src/lib/request-context.ts', muted: true }, + { path: 'src/transport.ts' }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/index.ts', muted: true }, + { path: 'src/routes/counter.ts' }, + { path: 'src/do', kind: 'folder' }, + { path: 'src/do/counter.ts' }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true } +] + +export const r2ConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + }, + bindings: { + r2: { + FILES: 'quickstart-files' + } + } +})` + +export const r2RouteCode = String.raw`import { env } from 'devflare/runtime' +import { + activeRequestPath, + activeRequestText, + activeRouteParam +} from '../../lib/request-context' + +export async function PUT(): Promise { + const key = activeRouteParam('name') + await env.FILES.put(key, await activeRequestText()) + + return Response.json({ + stored: key, + path: activeRequestPath() + }, { + status: 201 + }) +} + +export async function GET(): Promise { + const key = activeRouteParam('name') + const object = await env.FILES.get(key) + if (!object) { + return new Response('Not found', { status: 404 }) + } + + const response = new Response(object.body, { + headers: { + 'content-type': object.httpMetadata?.contentType ?? 'text/plain; charset=utf-8' + } + }) + response.headers.set('x-devflare-path', activeRequestPath()) + return response +}` + +export const r2BindingsStructure: DocCodeTreeEntry[] = [ + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts', muted: true }, + { path: 'src/lib', kind: 'folder' }, + { path: 'src/lib/request-context.ts', muted: true }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/index.ts', muted: true }, + { path: 'src/routes/files', kind: 'folder' }, + { path: 'src/routes/files/[name].ts' }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true } +] + +export const browserConfigCode = String.raw`import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + }, + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +})` + +export const browserRouteCode = String.raw`import puppeteer from '@cloudflare/puppeteer' +import { env } from 'devflare/runtime' +import { activeRequestId } from '../lib/request-context' + +export async function GET(): Promise { + const browser = await puppeteer.launch(env.BROWSER as Parameters[0]) + + try { + const page = await browser.newPage() + await page.goto('https://example.com/', { waitUntil: 'load' }) + return Response.json({ + title: await page.title(), + requestId: activeRequestId() + }) + } finally { + await browser.close() + } +}` + +export const browserBindingsStructure: DocCodeTreeEntry[] = [ + { path: 'package.json', muted: true }, + { path: 'devflare.config.ts' }, + { path: 'src', kind: 'folder' }, + { path: 'src/fetch.ts', muted: true }, + { path: 'src/lib', kind: 'folder' }, + { path: 'src/lib/request-context.ts', muted: true }, + { path: 'src/routes', kind: 'folder' }, + { path: 'src/routes/index.ts', muted: true }, + { path: 'src/routes/page-title.ts' }, + { path: 'tests', kind: 'folder', muted: true }, + { path: 'tests/fetch.test.ts', muted: true }, + { path: 'env.d.ts', muted: true } +] diff --git a/apps/documentation/src/lib/docs/content/start-here/support-coverage.ts b/apps/documentation/src/lib/docs/content/start-here/support-coverage.ts new file mode 100644 index 0000000..26e9911 --- /dev/null +++ b/apps/documentation/src/lib/docs/content/start-here/support-coverage.ts @@ -0,0 +1,189 @@ +import type { DocCard } from '../../types' +import { docsLink, supportCoverageTooltips } from './shared' + +function supportCard(card: Omit): DocCard { + const label = card.label as keyof typeof supportCoverageTooltips + + return { + ...card, + labelTooltip: supportCoverageTooltips[label] + } +} + +export const cloudflarePlatformSupportCards: DocCard[] = [ + supportCard({ + label: 'Full', + meta: 'Storage', + title: 'KV', + body: 'Named config, generated types, local runtime behavior, and `createTestContext()` or `createOfflineEnv()` tests for lookup state and lightweight shared data.', + href: docsLink('bindings/kv') + }), + supportCard({ + label: 'Full', + meta: 'Storage', + title: 'D1', + body: 'SQLite-style local behavior, id or name-based config, generated env typing, and realistic query tests through the same binding shape used in Workers.', + href: docsLink('bindings/d1') + }), + supportCard({ + label: 'Full', + meta: 'Storage', + title: 'R2', + body: 'Object storage config, local bucket behavior, generated env typing, and runtime-shaped tests. The caveat is Cloudflare object delivery URLs, not the binding itself.', + href: docsLink('bindings/r2') + }), + supportCard({ + label: 'Full', + meta: 'State', + title: 'Durable Objects', + body: 'Stateful object wiring, discovery, generated config, local namespaces, and test access, including cross-worker references. Preview lifecycle still follows Cloudflare limits.', + href: docsLink('bindings/durable-objects') + }), + supportCard({ + label: 'Full', + meta: 'Async', + title: 'Queues', + body: 'Producer and consumer config, local queue-trigger tests, generated env typing, and worker-surface composition for background work.', + href: docsLink('bindings/queues') + }), + supportCard({ + label: 'Full', + meta: 'Multi-worker', + title: 'Services', + body: '`ref()` service bindings, typed worker-to-worker env contracts, local multi-worker runtime, and tests that call the same service binding the app uses.', + href: docsLink('bindings/services') + }), + supportCard({ + label: 'Remote', + meta: 'Remote AI', + title: 'AI', + body: 'Native config, generated types, deploy support, and AI Gateway method coverage are present. Real inference, model behavior, billing, and most meaningful tests remain Cloudflare remote behavior.', + href: docsLink('bindings/ai') + }), + supportCard({ + label: 'Remote', + meta: 'Remote vector search', + title: 'Vectorize', + body: 'Native config, generated types, preview-aware resource naming, and remote-mode tests are supported. Real index semantics and similarity results require Cloudflare.', + href: docsLink('bindings/vectorize') + }), + supportCard({ + label: 'Full', + meta: 'Database path', + title: 'Hyperdrive', + body: 'Config, name resolution, local connection strings, and Miniflare-backed Hyperdrive bindings support ordinary app queries without Cloudflare. Hosted pooling, placement, credentials, and production routing remain Cloudflare behavior.', + href: docsLink('bindings/hyperdrive') + }), + supportCard({ + label: 'Full', + meta: 'Browser runtime', + title: 'Browser Rendering', + body: 'Native config, generated typing, route examples, and bridge-backed dev-server support through the local browser-rendering shim. Cloudflare still owns hosted session limits, live/HITL behavior, recordings, and billing.', + href: docsLink('bindings/browser-rendering') + }), + supportCard({ + label: 'Remote', + meta: 'Analytics', + title: 'Analytics Engine', + body: 'Dataset bindings are configured in Devflare, and app code can be thin-tested around `writeDataPoint()`. Production ingestion and analytics behavior remain hosted.', + href: docsLink('bindings/analytics-engine') + }), + supportCard({ + label: 'Full', + meta: 'Email', + title: 'Send Email', + body: 'Outbound email bindings have native config, local support, and test access through the env binding. Inbound email handlers are a separate Worker surface.', + href: docsLink('bindings/send-email') + }), + supportCard({ + label: 'Full', + meta: 'Rate limits', + title: 'Rate Limiting', + body: 'Native fixed-window config, Miniflare-backed local behavior, generated typing, and pure mocks support deterministic application-level rate-limit tests.', + href: docsLink('bindings/rate-limiting') + }), + supportCard({ + label: 'Full', + meta: 'Deployment metadata', + title: 'Version Metadata', + body: 'Native config, deterministic local metadata, and test helpers support version-aware responses and diagnostics without requiring Cloudflare state.', + href: docsLink('bindings/version-metadata') + }), + supportCard({ + label: 'Full', + meta: 'Dynamic workers', + title: 'Worker Loaders', + body: 'Devflare wires Worker Loader bindings through Miniflare and pure test stubs, so local apps can load explicit Worker payloads without Cloudflare. Upload, discovery, and hosted lifecycle stay on the platform.', + href: docsLink('bindings/worker-loaders') + }), + supportCard({ + label: 'Full', + meta: 'Secrets', + title: 'Secrets Store', + body: 'Native config, Miniflare wiring, and explicit local fixtures cover app code that reads Secrets Store values. Devflare still does not read, provision, or sync account secret values.', + href: docsLink('bindings/secrets-store') + }), + supportCard({ + label: 'Remote', + meta: 'Hosted search', + title: 'AI Search', + body: 'Native instance and namespace config plus deterministic fixtures can test application flow. Crawling, indexing, ranking, and hosted model behavior stay in Cloudflare.', + href: docsLink('bindings/ai-search') + }), + supportCard({ + label: 'Remote', + meta: 'Outbound TLS', + title: 'mTLS Certificates', + body: 'Native config and Fetcher-shaped local fixtures are supported. Real client-certificate presentation and certificate lifecycle remain Wrangler and Cloudflare remote behavior.', + href: docsLink('bindings/mtls-certificates') + }), + supportCard({ + label: 'Remote', + meta: 'Workers for Platforms', + title: 'Dispatch Namespaces', + body: 'Native dispatch namespace bindings and tenant Fetcher fixtures are supported. Devflare does not upload tenant Workers or emulate the Workers for Platforms control plane.', + href: docsLink('bindings/dispatch-namespaces') + }), + supportCard({ + label: 'Full', + meta: 'Long-running work', + title: 'Workflows', + body: 'Native config, Miniflare workflow bindings, deterministic mocks, and real WorkflowEntrypoint examples cover the local app loop. Production lifecycle, durability, retries, and scheduling remain Cloudflare-owned.', + href: docsLink('bindings/workflows') + }), + supportCard({ + label: 'Remote', + meta: 'Event ingestion', + title: 'Pipelines', + body: 'Native config and local send-recording tests are supported for producer code. Pipeline creation, batching, transformations, sinks, and delivery are Cloudflare-managed.', + href: docsLink('bindings/pipelines') + }), + supportCard({ + label: 'Full', + meta: 'Image processing', + title: 'Images', + body: 'Native singleton config, Miniflare image bindings, persisted local state, and deterministic pure mocks cover Worker image transform flows. Hosted storage, variants, delivery rules, billing, and final transform fidelity remain remote.', + href: docsLink('bindings/images') + }), + supportCard({ + label: 'Full', + meta: 'Media processing', + title: 'Media Transformations', + body: 'Native config, Miniflare media bindings, and deterministic pure mocks cover Worker media transform chains locally. Real codecs, output fidelity, duration handling, cache behavior, and billing remain hosted Cloudflare behavior.', + href: docsLink('bindings/media-transformations') + }), + supportCard({ + label: 'Remote', + meta: 'Git-like artifacts', + title: 'Artifacts', + body: 'Native config and in-memory repo or token fixtures are supported for app flow. Durable storage, Git-over-HTTPS remotes, namespace creation, and permissions are Cloudflare-owned.', + href: docsLink('bindings/artifacts') + }), + supportCard({ + label: 'Full', + meta: 'Containers', + title: 'Containers', + body: 'Native top-level container config has full local support through Docker or Podman: Devflare can build Dockerfile paths offline-first, run prebuilt image tags, and interact with launched instances. Deployed rollout, registry availability, SSH, scaling, and hosted platform behavior remain Cloudflare-owned.', + href: docsLink('bindings/containers') + }) +] diff --git a/apps/documentation/src/lib/docs/llm-response.ts b/apps/documentation/src/lib/docs/llm-response.ts new file mode 100644 index 0000000..4515c5c --- /dev/null +++ b/apps/documentation/src/lib/docs/llm-response.ts @@ -0,0 +1,22 @@ +import { buildLLMDocument, buildStrictLLMDocument } from './llm' + +export type LLMDocumentVariant = 'full' | 'strict' + +const llmDocumentBuilders = { + full: buildLLMDocument, + strict: buildStrictLLMDocument +} as const satisfies Record string> + +export function createLLMDocumentResponse(options: { + contentType: string + variant?: LLMDocumentVariant +}): Response { + const variant = options.variant ?? 'full' + const document = `${llmDocumentBuilders[variant]().trimEnd()}\n` + + return new Response(document, { + headers: { + 'content-type': options.contentType + } + }) +} \ No newline at end of file diff --git a/apps/documentation/src/lib/docs/llm.ts b/apps/documentation/src/lib/docs/llm.ts new file mode 100644 index 0000000..64def10 --- /dev/null +++ b/apps/documentation/src/lib/docs/llm.ts @@ -0,0 +1,345 @@ +import { docGroups, docPath } from './content' +import type { + DocCallout, + DocCard, + DocCodeFile, + DocCodeSnippet, + DocPage, + DocSection, + DocTable +} from './types' + +const CODE_FENCE = '```' +const STRICT_EXCLUDED_DOC_SLUGS = new Set(['documentation-contract']) + +type MarkdownBlock = string | false | null | undefined + +function escapeTableCell(value: string): string { + return value + .replace(/\|/g, '\\|') + .replace(/\r?\n+/g, ' / ') + .trim() +} + +function createHeading(level: number, title: string): string { + return `${'#'.repeat(level)} ${title}` +} + +function joinBlocks(blocks: MarkdownBlock[]): string { + return blocks + .map((block) => (typeof block === 'string' ? block.trim() : '')) + .filter((block) => block.length > 0) + .join('\n\n') +} + +function renderParagraphs(paragraphs: string[] | undefined): string[] { + return paragraphs ?? [] +} + +function renderBullets(items: string[] | undefined): string | undefined { + if (!items || items.length === 0) { + return undefined + } + + return items.map((item) => `- ${item}`).join('\n') +} + +function renderSteps(items: string[] | undefined): string | undefined { + if (!items || items.length === 0) { + return undefined + } + + return items.map((item, index) => `${index + 1}. ${item}`).join('\n') +} + +function renderCards(cards: DocCard[] | undefined): string | undefined { + if (!cards || cards.length === 0) { + return undefined + } + + return cards + .map((card) => { + const reference = card.href ? ` ([link](${card.href}))` : '' + return `- **${card.title}** โ€” ${card.body}${reference}` + }) + .join('\n') +} + +function renderTable(table: DocTable | undefined): string | undefined { + if (!table) { + return undefined + } + + const headerLine = `| ${table.headers.map(escapeTableCell).join(' | ')} |` + const dividerLine = `| ${table.headers.map(() => '---').join(' | ')} |` + const rowLines = table.rows.map((row) => `| ${row.map(escapeTableCell).join(' | ')} |`) + + return [headerLine, dividerLine, ...rowLines].join('\n') +} + +function renderBlockquote(paragraphs: string[]): string { + return paragraphs + .map((paragraph) => { + return paragraph + .split(/\r?\n/) + .map((line) => (line.trim().length > 0 ? `> ${line}` : '>')) + .join('\n') + }) + .join('\n>\n') +} + +function formatCalloutTone(callout: DocCallout): string { + switch (callout.tone) { + case 'success': + return 'Tip' + case 'warning': + return 'Warning' + case 'accent': + return 'Important' + default: + return 'Note' + } +} + +function renderCallouts(callouts: DocCallout[] | undefined): string[] { + if (!callouts || callouts.length === 0) { + return [] + } + + return callouts.map((callout) => { + const ctaLine = callout.cta + ? `${callout.cta.description ? `${callout.cta.description} ` : ''}[${callout.cta.label}](${callout.cta.href})` + : undefined + + return renderBlockquote([ + `**${formatCalloutTone(callout)} โ€” ${callout.title}**`, + ...callout.body, + ...(ctaLine ? [ctaLine] : []) + ]) + }) +} + +function renderSnippets(snippets: DocCodeSnippet[] | undefined, level: number): string[] { + if (!snippets || snippets.length === 0) { + return [] + } + + return snippets.map((snippet) => { + const blocks: MarkdownBlock[] = [ + createHeading(level, `Example โ€” ${snippet.title}`), + snippet.description + ] + + for (const file of getSnippetFiles(snippet)) { + blocks.push(...renderSnippetFile(file, snippet, level + 1)) + } + + return joinBlocks(blocks) + }) +} + +function getSnippetFiles(snippet: DocCodeSnippet): DocCodeFile[] { + if (snippet.files?.length) { + return snippet.files.filter((file) => file.code.trim().length > 0) + } + + if (!snippet.code?.trim()) { + return [] + } + + return [ + { + path: snippet.filename, + language: snippet.language, + code: snippet.code + } + ] +} + +function renderSnippetFile( + file: DocCodeFile, + snippet: DocCodeSnippet, + level: number +): MarkdownBlock[] { + const language = file.language ?? snippet.language ?? 'text' + const code = file.code.trim() + + if (!code) { + return [] + } + + return [ + file.path ? createHeading(level, `File โ€” ${file.path}`) : undefined, + `${CODE_FENCE}${language} +${code} +${CODE_FENCE}` + ] +} + +function renderLabeledBlock( + title: string, + body: string | undefined, + level: number +): string | undefined { + if (!body) { + return undefined + } + + return joinBlocks([createHeading(level, title), body]) +} + +function renderMetadataTable(doc: DocPage): string { + const route = docPath(doc.slug) + + return [ + '| Field | Value |', + '| --- | --- |', + `| Route | [\`${route}\`](${route}) |`, + `| Group | ${escapeTableCell(doc.group)} |`, + `| Navigation title | ${escapeTableCell(doc.navTitle)} |`, + `| Eyebrow | ${escapeTableCell(doc.eyebrow)} |` + ].join('\n') +} + +function renderFactsTable(doc: DocPage): string { + return [ + '| Fact | Value |', + '| --- | --- |', + ...doc.facts.map( + (fact) => `| ${escapeTableCell(fact.label)} | ${escapeTableCell(fact.value)} |` + ) + ].join('\n') +} + +function renderSection(section: DocSection, level = 4): string { + const subLevel = level + 1 + + return joinBlocks([ + createHeading(level, section.title), + section.description, + ...renderParagraphs(section.paragraphs), + renderLabeledBlock('Highlights', renderCards(section.cards), subLevel), + renderLabeledBlock('Key points', renderBullets(section.bullets), subLevel), + renderLabeledBlock('Steps', renderSteps(section.steps), subLevel), + renderLabeledBlock('Reference table', renderTable(section.table), subLevel), + ...renderCallouts(section.callouts), + ...renderSnippets(section.snippets, subLevel) + ]) +} + +function renderDistinctParagraphs(paragraphs: Array): string[] { + const rendered: string[] = [] + const seen = new Set() + + for (const paragraph of paragraphs) { + const normalizedParagraph = paragraph?.trim() + + if (!normalizedParagraph || seen.has(normalizedParagraph)) { + continue + } + + seen.add(normalizedParagraph) + rendered.push(normalizedParagraph) + } + + return rendered +} + +function renderDocPage(doc: DocPage): string { + return joinBlocks([ + createHeading(3, doc.title), + renderBlockquote([doc.summary]), + renderMetadataTable(doc), + doc.description, + renderLabeledBlock('At a glance', renderFactsTable(doc), 4), + ...doc.sections.map((section) => renderSection(section, 4)) + ]) +} + +function renderStrictDocPage(doc: DocPage): string { + return joinBlocks([ + createHeading(2, doc.title), + `Route: \`${docPath(doc.slug)}\``, + ...renderDistinctParagraphs([doc.summary, doc.description]), + renderLabeledBlock('Key takeaways', renderBullets(doc.highlights), 3), + ...doc.sections.map((section) => renderSection(section, 3)) + ]) +} + +function getUniqueOrderedDocs(): DocPage[] { + const orderedDocs = docGroups.flatMap((group) => + group.categories.flatMap((category) => category.items) + ) + const seenSlugs = new Set() + + return orderedDocs.filter((doc) => { + if (seenSlugs.has(doc.slug)) { + return false + } + + seenSlugs.add(doc.slug) + return true + }) +} + +function renderDocumentationMap(totalDocs: number): string { + const lines: string[] = [ + createHeading(2, 'Documentation map'), + `This export covers ${totalDocs} pages across ${docGroups.length} top-level groups.` + ] + + for (const group of docGroups) { + lines.push('', createHeading(3, group.title), group.description) + + for (const category of group.categories) { + if (category.sidebarDisplay === 'standalone') { + lines.push( + ...category.items.map( + (item) => `- [${item.navTitle}](${docPath(item.slug)}) โ€” ${item.summary}` + ) + ) + continue + } + + lines.push('', `- **${category.title}** โ€” ${category.description}`) + lines.push( + ...category.items.map( + (item) => ` - [${item.navTitle}](${docPath(item.slug)}) โ€” ${item.summary}` + ) + ) + } + } + + return lines.join('\n') +} + +export function buildLLMDocument(): string { + const uniqueDocs = getUniqueOrderedDocs() + + return joinBlocks([ + '# Devflare documentation markdown export', + 'This file is generated from the structured documentation model in `apps/documentation/src/lib/docs/content*.ts` during the documentation build and deploy pipeline.', + 'Do not edit this file by hand; update the docs model and regenerate the export instead.', + 'It is meant to read like a proper markdown handbook rather than a second source of truth, so the docs site and the `LLM.md` export stay aligned.', + createHeading(2, 'How to use this export'), + renderBullets([ + 'Read the documentation map first to find the relevant page and route quickly.', + 'Each page includes a short summary, metadata, key takeaways, and the fully expanded sections from the docs source.', + 'Links use the same `/docs/...` routes as the documentation site.' + ]), + renderDocumentationMap(uniqueDocs.length), + createHeading(2, 'Full documentation'), + uniqueDocs.map(renderDocPage).join('\n\n---\n\n') + ]) +} + +export function buildStrictLLMDocument(): string { + const strictDocs = getUniqueOrderedDocs().filter( + (doc) => !STRICT_EXCLUDED_DOC_SLUGS.has(doc.slug) + ) + + return joinBlocks([ + '# Devflare documentation', + strictDocs.map(renderStrictDocPage).join('\n\n---\n\n') + ]) +} diff --git a/apps/documentation/src/lib/docs/types.ts b/apps/documentation/src/lib/docs/types.ts new file mode 100644 index 0000000..2cd93c9 --- /dev/null +++ b/apps/documentation/src/lib/docs/types.ts @@ -0,0 +1,129 @@ +export type DocCalloutTone = 'info' | 'success' | 'warning' | 'accent' + +export interface DocCalloutCta { + label: string + href: string + description?: string +} + +export interface DocCallout { + tone?: DocCalloutTone + title: string + body: string[] + cta?: DocCalloutCta +} + +export type DocCodeLineRange = number | [number, number] + +export interface DocCodeFile { + path?: string + label?: string + language?: string + code: string + focusLines?: DocCodeLineRange[] + dimLines?: DocCodeLineRange[] + startLine?: number + copyCode?: string +} + +export interface DocCodeTreeEntry { + path: string + kind?: 'file' | 'folder' + muted?: boolean +} + +export interface DocCodeSnippet { + title: string + description?: string + language?: string + code?: string + filename?: string + files?: DocCodeFile[] + structure?: DocCodeTreeEntry[] + activeFile?: string +} + +export interface DocTable { + headers: string[] + rows: string[][] + layout?: 'default' | 'wide' +} + +export interface DocCard { + title: string + body: string + href?: string + label?: string + labelTooltip?: string + meta?: string +} + +export interface DocFact { + label: string + value: string +} + +export interface DocHeaderCloudflareDocs { + label: string + title: string + href: string + summary: string +} + +export interface DocHeaderSupport { + label: string + tooltip: string +} + +export interface DocSection { + id: string + title: string + label?: string + labelTooltip?: string + description?: string + paragraphs?: string[] + bullets?: string[] + steps?: string[] + cards?: DocCard[] + callouts?: DocCallout[] + snippets?: DocCodeSnippet[] + table?: DocTable +} + +export interface DocPage { + slug: string + aliases?: string[] + group: string + navTitle: string + sidebarHidden?: boolean + readTime?: string + eyebrow: string + title: string + summary: string + summaryHidden?: boolean + description: string + descriptionHidden?: boolean + headerCloudflareDocs?: DocHeaderCloudflareDocs + headerSupport?: DocHeaderSupport + articleNavigationHidden?: boolean + highlights: string[] + facts: DocFact[] + sourcePages: string[] + sections: DocSection[] +} + +export interface DocCategory { + id: string + title: string + description: string + sidebarDisplay?: 'disclosure' | 'links' | 'standalone' + items: DocPage[] + sidebarItems?: DocPage[] +} + +export interface DocGroup { + title: string + description: string + categories: DocCategory[] + items: DocPage[] +} diff --git a/apps/documentation/src/lib/i18n/routing.ts b/apps/documentation/src/lib/i18n/routing.ts new file mode 100644 index 0000000..512885b --- /dev/null +++ b/apps/documentation/src/lib/i18n/routing.ts @@ -0,0 +1,7 @@ +export const documentationLocales = ['en'] as const + +export type DocumentationLocale = (typeof documentationLocales)[number] + +export function localizeDocSlug(slug: string, _locale: string): string { + return slug +} diff --git a/apps/documentation/src/lib/index.ts b/apps/documentation/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/apps/documentation/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/documentation/src/lib/intellisense/IntellisenseTooltip.svelte b/apps/documentation/src/lib/intellisense/IntellisenseTooltip.svelte new file mode 100644 index 0000000..f180c7c --- /dev/null +++ b/apps/documentation/src/lib/intellisense/IntellisenseTooltip.svelte @@ -0,0 +1,121 @@ + + +{#if intellisense.visible && intellisense.content} + {@const requirementLabel = getRequirementLabel(intellisense.content.requirement)} + {@const hasCloudflareReference = intellisense.content.references?.some((link) => isCloudflareReference(link)) ?? false} + + +{/if} diff --git a/apps/documentation/src/lib/intellisense/controller.ts b/apps/documentation/src/lib/intellisense/controller.ts new file mode 100644 index 0000000..06dfad2 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/controller.ts @@ -0,0 +1,61 @@ +import { createSingleton, flip, offset, shift } from '$lib/vendor/floating-runes' +import type { IntellisenseEntry } from './types' + +export const intellisense = createSingleton({ + placement: 'top-start', + strategy: 'fixed', + middleware: [ + offset(12), + flip({ padding: 12 }), + shift({ padding: 12 }) + ], + showDelay: 0, + hideDelay: 0, + showOn: [], + hideOn: [] +}) + +let hideHandle: ReturnType | undefined +let tooltipHovered = false + +export function cancelHideIntellisense(): void { + if (hideHandle) { + clearTimeout(hideHandle) + hideHandle = undefined + } +} + +export function showIntellisense(entry: IntellisenseEntry, anchor: HTMLElement): void { + cancelHideIntellisense() + intellisense.show(entry, anchor) +} + +export function scheduleHideIntellisense(delay = 220): void { + cancelHideIntellisense() + + if (tooltipHovered) { + return + } + + hideHandle = setTimeout(() => { + if (!tooltipHovered) { + intellisense.hide() + } + }, delay) +} + +export function hideIntellisense(): void { + cancelHideIntellisense() + intellisense.hide() +} + +export function setIntellisenseTooltipHovered(next: boolean): void { + tooltipHovered = next + + if (next) { + cancelHideIntellisense() + return + } + + scheduleHideIntellisense(140) +} diff --git a/apps/documentation/src/lib/intellisense/registry.ts b/apps/documentation/src/lib/intellisense/registry.ts new file mode 100644 index 0000000..eba9100 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry.ts @@ -0,0 +1,219 @@ +import { definitionsPart1 } from './registry/definitions-1' +import { definitionsPart2 } from './registry/definitions-2' +import { definitionsPart3 } from './registry/definitions-3' +import { definitionsPart4 } from './registry/definitions-4' +import { configFilePattern } from './registry/shared' +import type { + IntellisenseContextTag, + IntellisenseDefinition, + IntellisenseEntry, + IntellisenseRenderContext +} from './types' + +const definitions = [definitionsPart1, definitionsPart2, definitionsPart3, definitionsPart4].flat() + +const entriesById = new Map( + definitions.map((definition) => { + const { + aliases, + contexts, + filePatterns, + codeIncludes, + lineIncludes, + lineExcludes, + propertyPaths, + propertyPathSuffixes, + ...entry + } = definition + + void aliases + void contexts + void filePatterns + void codeIncludes + void lineIncludes + void lineExcludes + void propertyPaths + void propertyPathSuffixes + + return [definition.id, entry] + }) +) + +function normalizeSource(value: string | undefined): string { + return value?.toLowerCase() ?? '' +} + +function matchesPropertyPath(path: string, pattern: string): boolean { + const pathSegments = path.split('.') + const patternSegments = pattern.split('.') + + if (pathSegments.length !== patternSegments.length) { + return false + } + + return patternSegments.every((segment, index) => { + return segment === '*' || segment === pathSegments[index] + }) +} + +function matchesPropertyPathSuffix(path: string, suffix: string): boolean { + const pathSegments = path.split('.') + const suffixSegments = suffix.split('.') + + if (suffixSegments.length > pathSegments.length) { + return false + } + + const offset = pathSegments.length - suffixSegments.length + + return suffixSegments.every((segment, index) => { + return segment === '*' || segment === pathSegments[offset + index] + }) +} + +export function normalizeIntellisenseToken(token: string): string { + let normalized = token.trim() + normalized = normalized.replace(/^[`'"([{]+/, '') + normalized = normalized.replace(/[)\]}',;:"`]+$/, '') + normalized = normalized.replace(/\(\)$/, '') + return normalized.trim().toLowerCase() +} + +function getContextTags(context: IntellisenseRenderContext): Set { + const tags = new Set() + const language = context.language.trim().toLowerCase() + const code = normalizeSource(context.code) + const filePath = normalizeSource(context.filePath) + + switch (language) { + case 'bash': + case 'shell': + case 'sh': + tags.add('shell') + break + case 'yaml': + case 'yml': + tags.add('yaml') + break + case 'json': + case 'jsonc': + tags.add('json') + break + default: + break + } + + if (configFilePattern.test(context.filePath ?? '') || code.includes('devflare/config')) { + tags.add('config') + } + + if (code.includes('devflare/runtime')) { + tags.add('runtime') + } + + if ( + filePath.includes('/tests/') || + filePath.includes('test.') || + code.includes('devflare/test') || + code.includes('bun:test') + ) { + tags.add('test') + } + + if (tags.size === 0) { + tags.add('unknown') + } + + return tags +} + +function matchesDefinition( + definition: IntellisenseDefinition, + normalizedToken: string, + context: IntellisenseRenderContext, + contextTags: Set +): boolean { + if (context.tokenType?.toLowerCase() === 'comment') { + return false + } + + if (!definition.aliases.some((alias) => normalizeIntellisenseToken(alias) === normalizedToken)) { + return false + } + + if (definition.contexts?.length && !definition.contexts.some((tag) => contextTags.has(tag))) { + return false + } + + const filePath = context.filePath ?? '' + if ( + definition.filePatterns?.length && + !definition.filePatterns.some((pattern) => pattern.test(filePath)) + ) { + return false + } + + const code = normalizeSource(context.code) + if ( + definition.codeIncludes?.length && + !definition.codeIncludes.every((part) => code.includes(part.toLowerCase())) + ) { + return false + } + + const lineText = normalizeSource(context.lineText) + if ( + definition.lineIncludes?.length && + !definition.lineIncludes.every((part) => lineText.includes(part.toLowerCase())) + ) { + return false + } + + if (definition.lineExcludes?.some((part) => lineText.includes(part.toLowerCase()))) { + return false + } + + const propertyPath = context.propertyPath + if (definition.propertyPaths?.length) { + if ( + !propertyPath || + !definition.propertyPaths.some((pattern) => matchesPropertyPath(propertyPath, pattern)) + ) { + return false + } + } + + if (definition.propertyPathSuffixes?.length) { + if ( + !propertyPath || + !definition.propertyPathSuffixes.some((suffix) => + matchesPropertyPathSuffix(propertyPath, suffix) + ) + ) { + return false + } + } + + return true +} + +export function resolveIntellisenseEntry( + token: string, + context: IntellisenseRenderContext +): IntellisenseEntry | undefined { + const normalizedToken = normalizeIntellisenseToken(token) + if (!normalizedToken) { + return undefined + } + + const contextTags = getContextTags(context) + const match = definitions.find((definition) => { + return matchesDefinition(definition, normalizedToken, context, contextTags) + }) + + return match ? entriesById.get(match.id) : undefined +} + +export function getIntellisenseEntryById(id: string): IntellisenseEntry | undefined { + return entriesById.get(id) +} diff --git a/apps/documentation/src/lib/intellisense/registry/definitions-1.ts b/apps/documentation/src/lib/intellisense/registry/definitions-1.ts new file mode 100644 index 0000000..8cc6378 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry/definitions-1.ts @@ -0,0 +1,691 @@ +import { docPath, getCanonicalDocSlug } from '$lib/docs/content' +import type { IntellisenseDefinition, IntellisenseLink } from '../types' +import { configFilePattern } from './shared' + +function docsReference(label: string, slug: string): IntellisenseLink { + return { + label, + href: docPath(getCanonicalDocSlug(slug) ?? slug) + } +} + +function cloudflareReference(label: string, href: string): IntellisenseLink { + return { + label, + href, + external: true, + citation: 'Cloudflare Docs' + } +} + +export const definitionsPart1: IntellisenseDefinition[] = [ + { + id: 'module-devflare-config', + label: 'devflare/config', + kind: 'module', + aliases: ['devflare/config'], + contexts: ['config'], + summary: 'Config-only public entry for devflare.config.ts files.', + detail: + 'Use this module in config files so Bun only loads the lightweight config helpers instead of the full Node-side Devflare barrel.', + requirement: 'contextual', + availableIn: 'devflare.config.ts', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('First worker', 'first-worker') + ] + }, + { + id: 'module-devflare-runtime', + label: 'devflare/runtime', + kind: 'module', + aliases: ['devflare/runtime'], + contexts: ['runtime'], + summary: + 'Worker-safe runtime entry for request-scoped helpers, event types, and middleware utilities.', + detail: + 'Import runtime helpers from here inside worker code when you need FetchEvent types, context getters, sequence(), or request-scoped proxies such as locals.', + requirement: 'contextual', + availableIn: 'Worker and middleware files', + references: [ + docsReference('Runtime context', 'runtime-context'), + docsReference('sequence(...) middleware', 'sequence-middleware') + ] + }, + { + id: 'module-devflare-test', + label: 'devflare/test', + kind: 'module', + aliases: ['devflare/test'], + contexts: ['test'], + summary: 'Runtime-shaped test entry that exposes createTestContext() and cf.* helpers.', + detail: + 'Prefer this over hand-rolled mocks when you want Bun tests to exercise the same bindings and handler surfaces your worker actually uses.', + requirement: 'contextual', + availableIn: 'Bun tests', + references: [ + docsReference('createTestContext()', 'create-test-context'), + docsReference('Testing and automation', 'testing-and-automation') + ] + }, + { + id: 'module-devflare', + label: 'devflare', + kind: 'module', + aliases: ['devflare'], + contexts: ['runtime', 'test'], + codeIncludes: ["from 'devflare'"], + summary: + 'Main public Devflare entry used for unified helpers such as env in runtime and tests.', + detail: + 'In worker code, prefer devflare/runtime for request-scoped helpers and devflare/config for config files. The main entry is most useful when you intentionally want the unified env proxy.', + requirement: 'contextual', + availableIn: 'Worker code and tests', + references: [ + docsReference('Runtime context', 'runtime-context'), + docsReference('createTestContext()', 'create-test-context') + ] + }, + { + id: 'define-config', + label: 'defineConfig()', + kind: 'config', + aliases: ['defineconfig'], + contexts: ['config'], + codeIncludes: ['devflare/config'], + summary: + 'Typed wrapper for devflare.config.ts that preserves autocomplete and schema-friendly authoring.', + detail: + 'It accepts a plain object or a config factory. Devflare later validates the result, applies defaults, and compiles it into Wrangler-facing output.', + requirement: 'contextual', + availableIn: 'devflare.config.ts', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('First worker', 'first-worker') + ] + }, + { + id: 'preview-helper', + label: 'preview', + kind: 'config', + aliases: ['preview'], + contexts: ['config'], + codeIncludes: ['devflare/config'], + lineIncludes: ['preview.scope'], + summary: + 'Preview naming helper for resources that should materialize differently in named preview scopes.', + detail: + 'preview.scope() returns a function that marks names as preview-scoped. Devflare later materializes those names from preview identifier inputs such as environment or branch metadata.', + defaultValue: 'Separator defaults to -', + requirement: 'contextual', + availableIn: 'devflare.config.ts', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'ref-helper', + label: 'ref()', + kind: 'config', + aliases: ['ref'], + contexts: ['config'], + codeIncludes: ['devflare/config'], + summary: + 'Cross-worker reference helper that keeps service and entrypoint relationships explicit.', + detail: + 'Use ref() when one worker should point at another worker or named entrypoint without scattering worker names through source files or tests.', + requirement: 'contextual', + availableIn: 'devflare.config.ts', + references: [ + docsReference('Worker composition', 'multi-workers'), + docsReference('Config basics', 'config-basics') + ] + }, + { + id: 'config-name', + label: 'name', + kind: 'config', + aliases: ['name'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['name'], + summary: 'Worker name used as the deployment target and control-plane identity.', + detail: + 'This is the primary worker identifier Devflare compiles into Wrangler output. Review it like an external-facing name, not a throwaway label.', + requirement: 'required', + availableIn: 'Top-level devflare config', + references: [docsReference('Config basics', 'config-basics')] + }, + { + id: 'config-account-id', + label: 'accountId', + kind: 'config', + aliases: ['accountid'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['accountId'], + summary: 'Cloudflare account ID for flows that must target a specific remote account.', + detail: + 'Devflare only needs this when the flow must resolve or operate on remote account resources such as AI, Vectorize, or account-scoped inventories.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('Devflare CLI', 'devflare-cli') + ] + }, + { + id: 'config-compatibility-date', + label: 'compatibilityDate', + kind: 'config', + aliases: ['compatibilitydate'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['compatibilityDate'], + summary: 'Cloudflare Workers compatibility date for the worker runtime contract.', + detail: + 'Devflare passes this through to Wrangler and Miniflare so local dev, tests, and deploys all agree on the same Workers feature baseline.', + defaultValue: 'Current date in YYYY-MM-DD format', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference( + 'Compatibility dates', + 'https://developers.cloudflare.com/workers/configuration/compatibility-dates/' + ) + ] + }, + { + id: 'config-compatibility-flags', + label: 'compatibilityFlags', + kind: 'config', + aliases: ['compatibilityflags'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['compatibilityFlags'], + summary: 'Extra Cloudflare Workers compatibility flags layered on top of Devflare defaults.', + detail: + 'Devflare always includes nodejs_compat and nodejs_als, then merges any additional flags you specify here.', + defaultValue: "['nodejs_compat', 'nodejs_als'] are always included", + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference( + 'Compatibility flags', + 'https://developers.cloudflare.com/workers/configuration/compatibility-flags/' + ) + ] + }, + { + id: 'config-previews', + label: 'previews', + kind: 'config', + aliases: ['previews'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['previews'], + summary: + 'Preview-specific Devflare behavior for named preview scopes and preview deploy flows.', + detail: + 'This is where Devflare-specific preview behavior lives. Today it includes options such as includeCrons so preview environments stay deliberate instead of accidentally mimicking production.', + defaultValue: 'includeCrons defaults to false', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'config-files', + label: 'files', + kind: 'config', + aliases: ['files'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['files'], + summary: 'Author-facing map of worker handler files and discovery globs.', + detail: + 'Use this to pin or disable fetch, queue, scheduled, email, route, workflow, and transport surfaces instead of letting the project structure stay implicit.', + defaultValue: 'Auto-discovers standard worker surfaces from src/* and src/routes/**', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('Routing', 'http-routing') + ] + }, + { + id: 'config-bindings', + label: 'bindings', + kind: 'config', + aliases: ['bindings'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings'], + summary: 'Binding groups for Cloudflare resources and worker-to-worker relationships.', + detail: + 'Devflare keeps the authored binding shape readable, then compiles it into the correct Wrangler-facing form for the platform feature you are targeting.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Storage strategy', 'storage-bindings'), + docsReference('Worker composition', 'multi-workers') + ] + }, + { + id: 'config-triggers', + label: 'triggers', + kind: 'config', + aliases: ['triggers'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['triggers'], + summary: 'Trigger configuration for scheduled or cron-style handler surfaces.', + detail: + 'Use triggers when the worker should receive scheduled invocations rather than only HTTP traffic.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference( + 'Cron triggers', + 'https://developers.cloudflare.com/workers/configuration/cron-triggers/' + ) + ] + }, + { + id: 'config-vars', + label: 'vars', + kind: 'config', + aliases: ['vars'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['vars'], + summary: 'Plain-text environment variables exposed on env at runtime.', + detail: + 'Use vars for non-secret configuration that should be typed and available alongside the rest of the worker binding surface.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('First bindings', 'first-bindings'), + docsReference('Config basics', 'config-basics') + ] + }, + { + id: 'config-secrets', + label: 'secrets', + kind: 'config', + aliases: ['secrets'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['secrets'], + summary: 'Secret declarations that become part of the typed runtime env surface.', + detail: + 'Secrets tell Devflare which sensitive values exist even when you do not want those values hard-coded in source. Individual secret declarations are required by default.', + defaultValue: 'Each secret.required defaults to true', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('First bindings', 'first-bindings') + ] + }, + { + id: 'config-assets', + label: 'assets', + kind: 'config', + aliases: ['assets'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['assets'], + summary: + 'Static asset directory configuration for workers that ship compiled frontends or other static output.', + detail: + 'Devflare uses this when the worker should expose built assets, often for app shells or framework adapters that generate a static output directory.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Vite standalone', 'vite-standalone'), + cloudflareReference( + 'Workers static assets', + 'https://developers.cloudflare.com/workers/static-assets/' + ) + ] + }, + { + id: 'config-migrations', + label: 'migrations', + kind: 'config', + aliases: ['migrations'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['migrations'], + summary: 'Durable Object migration history that Devflare passes through to Wrangler.', + detail: + 'Keep this list explicit when Durable Object classes are added, renamed, or deleted so deploy-time state transitions stay honest.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Durable Object binding guide', 'bindings/durable-objects'), + cloudflareReference( + 'Durable Object migrations', + 'https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/' + ) + ] + }, + { + id: 'config-env-overrides', + label: 'env', + kind: 'config', + aliases: ['env'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['env:'], + propertyPaths: ['env'], + summary: 'Named environment overrides layered on top of the base Devflare config.', + detail: + 'Build and deploy flows can resolve config.env[name] before compilation, which keeps staging or preview tweaks explicit without cloning the whole config file.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('Devflare CLI', 'devflare-cli') + ] + }, + { + id: 'config-wrangler', + label: 'wrangler', + kind: 'config', + aliases: ['wrangler'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['wrangler'], + summary: + 'Escape hatch for Wrangler passthrough when Devflare does not model an option directly.', + detail: + 'Prefer first-class Devflare fields when they exist. Reach for wrangler.passthrough only when you truly need a Wrangler-specific option that Devflare has not exposed yet.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + docsReference('Vite standalone', 'vite-standalone') + ] + }, + { + id: 'config-rolldown', + label: 'rolldown', + kind: 'config', + aliases: ['rolldown'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['rolldown'], + summary: + 'Rolldown-specific build configuration used by Devflare for worker and Durable Object bundling lanes.', + detail: + 'Use this when you need bundler configuration at the Devflare layer rather than only inside a host framework or local Vite config.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Svelte with Rolldown', 'svelte-with-rolldown'), + docsReference('Vite standalone', 'vite-standalone') + ] + }, + { + id: 'config-vite', + label: 'vite', + kind: 'config', + aliases: ['vite'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['vite'], + summary: 'Vite-related namespace for Devflare-aware app workflows.', + detail: + 'Devflare can detect and cooperate with local Vite projects automatically, but this namespace is where Devflare-specific Vite coordination lives when you need to be explicit.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Vite standalone', 'vite-standalone'), + docsReference('SvelteKit with Devflare', 'sveltekit-with-devflare') + ] + }, + { + id: 'config-routes', + label: 'routes', + kind: 'config', + aliases: ['routes'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['routes'], + summary: 'Cloudflare deployment routes that decide which traffic reaches the worker.', + detail: + 'These are deployment-time route patterns, not the file-router settings under files.routes. Use them when you need hostname or zone-level traffic attachment in authored config.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + cloudflareReference( + 'Workers routes', + 'https://developers.cloudflare.com/workers/configuration/routing/routes/' + ) + ] + }, + { + id: 'config-ws-routes', + label: 'wsRoutes', + kind: 'config', + aliases: ['wsroutes'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['wsRoutes'], + summary: + 'Development WebSocket proxy rules for forwarding paths into Durable Object namespaces.', + detail: + 'Use these when local development should proxy WebSocket traffic into a Durable Object namespace through an explicit path contract.', + requirement: 'optional', + availableIn: 'Top-level devflare config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'config-limits', + label: 'limits', + kind: 'config', + aliases: ['limits'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['limits'], + summary: 'Runtime resource limits such as CPU time.', + detail: + 'Keep these limits in authored config when the package has explicit runtime expectations that should survive local review and deploy automation.', + requirement: 'optional', + availableIn: 'Top-level devflare config and env overlays', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'config-observability', + label: 'observability', + kind: 'config', + aliases: ['observability'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['observability'], + summary: 'Tracing and sampling posture for the worker.', + detail: + 'Use this lane when observability settings should stay explicit in source instead of being rediscovered in deployment settings later.', + requirement: 'optional', + availableIn: 'Top-level devflare config and env overlays', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + docsReference('Environments', 'config-environments') + ] + }, + { + id: 'files-fetch', + label: 'files.fetch', + kind: 'config', + aliases: ['fetch'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['fetch:'], + propertyPathSuffixes: ['files.fetch'], + summary: 'Explicit path to the main HTTP handler file.', + detail: + 'Point this at your fetch surface when you want the project contract to stay explicit. Setting it to false disables the fetch surface instead of leaving discovery ambiguous.', + defaultValue: 'src/fetch.ts', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('First worker', 'first-worker'), + docsReference('Routing', 'http-routing') + ] + }, + { + id: 'files-queue', + label: 'files.queue', + kind: 'config', + aliases: ['queue'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['queue:'], + propertyPathSuffixes: ['files.queue'], + summary: 'Explicit path to the queue consumer handler surface.', + detail: + 'Use this when queue consumption should stay explicit in source review. Setting it to false disables queue handler discovery for this worker.', + defaultValue: 'src/queue.ts', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Queue binding guide', 'bindings/queues'), + cloudflareReference('Queues docs', 'https://developers.cloudflare.com/queues/') + ] + }, + { + id: 'files-scheduled', + label: 'files.scheduled', + kind: 'config', + aliases: ['scheduled'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['scheduled:'], + propertyPathSuffixes: ['files.scheduled'], + summary: 'Explicit path to the scheduled-event handler surface.', + detail: + 'Use this when the worker should receive cron-style scheduled events and you want the file contract to stay visible in source review.', + defaultValue: 'src/scheduled.ts', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference( + 'Cron triggers', + 'https://developers.cloudflare.com/workers/configuration/cron-triggers/' + ) + ] + }, + { + id: 'files-email', + label: 'files.email', + kind: 'config', + aliases: ['email'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['email:'], + propertyPathSuffixes: ['files.email'], + summary: 'Explicit path to the email handler surface for Email Workers flows.', + detail: + 'Use this when the worker should receive inbound email events rather than only HTTP requests or queue jobs.', + defaultValue: 'src/email.ts', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference( + 'Email Workers', + 'https://developers.cloudflare.com/email-routing/email-workers/' + ) + ] + }, + { + id: 'files-durable-objects', + label: 'files.durableObjects', + kind: 'config', + aliases: ['durableobjects'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['durableobjects:'], + propertyPathSuffixes: ['files.durableObjects'], + summary: 'Glob pattern used to discover Durable Object source files.', + detail: + 'Use this when your Durable Object classes do not live on the default do.* file pattern or when you want discovery to stay explicit in the config.', + defaultValue: '**/do.*.{ts,js}', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Durable Object binding guide', 'bindings/durable-objects'), + docsReference('State & async patterns', 'durable-objects-and-queues') + ] + }, + { + id: 'files-entrypoints', + label: 'files.entrypoints', + kind: 'config', + aliases: ['entrypoints'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['entrypoints:'], + propertyPathSuffixes: ['files.entrypoints'], + summary: 'Entrypoint discovery glob for multi-entry worker setups.', + detail: + 'Use this when named entrypoints should be discovered from a non-default location or when you want that discovery pattern to stay explicit in source.', + defaultValue: '**/ep.*.{ts,js}', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Worker composition', 'multi-workers'), + docsReference('Config basics', 'config-basics') + ] + }, + { + id: 'files-routes', + label: 'files.routes', + kind: 'config', + aliases: ['routes'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['routes:'], + propertyPathSuffixes: ['files.routes'], + summary: 'Built-in file-router configuration for route-module discovery.', + detail: + 'Use this to change the route directory or route prefix when the default src/routes tree is not the shape you want Devflare to scan.', + defaultValue: 'dir: src/routes', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Routing', 'http-routing'), + docsReference('First worker', 'first-worker') + ] + }, + { + id: 'files-workflows', + label: 'files.workflows', + kind: 'config', + aliases: ['workflows'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['workflows:'], + propertyPathSuffixes: ['files.workflows'], + summary: 'Workflow discovery path for workflow-oriented project setups.', + detail: + 'Use this when workflow surfaces should be discovered from a specific source location rather than assumed from defaults.', + defaultValue: '**/wf.*.{ts,js}', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Config basics', 'config-basics'), + cloudflareReference('Workflows', 'https://developers.cloudflare.com/workflows/') + ] + } +] diff --git a/apps/documentation/src/lib/intellisense/registry/definitions-2.ts b/apps/documentation/src/lib/intellisense/registry/definitions-2.ts new file mode 100644 index 0000000..cb16670 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry/definitions-2.ts @@ -0,0 +1,655 @@ +import { docPath, getCanonicalDocSlug } from '$lib/docs/content' +import type { IntellisenseDefinition, IntellisenseLink } from '../types' +import { configFilePattern } from './shared' + +function docsReference(label: string, slug: string): IntellisenseLink { + return { + label, + href: docPath(getCanonicalDocSlug(slug) ?? slug) + } +} + +function cloudflareReference(label: string, href: string): IntellisenseLink { + return { + label, + href, + external: true, + citation: 'Cloudflare Docs' + } +} + +export const definitionsPart2: IntellisenseDefinition[] = [ + { + id: 'files-transport', + label: 'files.transport', + kind: 'config', + aliases: ['transport'], + contexts: ['config'], + filePatterns: [configFilePattern], + lineIncludes: ['transport:'], + propertyPathSuffixes: ['files.transport'], + summary: 'Optional transport map path for bridge-backed test serialization of custom classes.', + detail: + 'Use this only when bridge-backed tests need to round-trip custom class instances that do not cross the worker boundary as plain JSON by default.', + defaultValue: 'src/transport.ts', + requirement: 'optional', + availableIn: 'files section of devflare config', + references: [ + docsReference('Transport file', 'transport-file'), + docsReference('createTestContext()', 'create-test-context') + ] + }, + { + id: 'previews-include-crons', + label: 'previews.includeCrons', + kind: 'config', + aliases: ['includecrons'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['previews.includeCrons'], + summary: 'Choose whether preview deployments keep cron triggers enabled.', + detail: + 'This defaults to false so preview environments do not inherit scheduled behavior by accident. Opt in only when the preview should exercise real cron behavior.', + defaultValue: 'false', + requirement: 'optional', + availableIn: 'previews section of devflare config and env overlays', + references: [ + docsReference('Worker surfaces', 'worker-surfaces'), + docsReference('Runtime & deploy settings', 'runtime-deploy-settings') + ] + }, + { + id: 'triggers-crons', + label: 'triggers.crons', + kind: 'config', + aliases: ['crons'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['triggers.crons'], + summary: 'Cron schedule list for the scheduled worker surface.', + detail: + 'Use this when a scheduled handler should run on explicit cron expressions owned by the config instead of living in shell comments or team memory.', + requirement: 'optional', + availableIn: 'triggers section of devflare config and env overlays', + references: [ + docsReference('Worker surfaces', 'worker-surfaces'), + cloudflareReference( + 'Cron triggers', + 'https://developers.cloudflare.com/workers/configuration/cron-triggers/' + ) + ] + }, + { + id: 'files-routes-dir', + label: 'files.routes.dir', + kind: 'config', + aliases: ['dir'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['files.routes.dir'], + summary: 'Directory Devflare scans for route modules.', + detail: + 'Use this when the route tree lives somewhere other than the default src/routes directory.', + defaultValue: 'src/routes', + requirement: 'optional', + availableIn: 'files.routes config', + references: [docsReference('Routing', 'http-routing')] + }, + { + id: 'files-routes-prefix', + label: 'files.routes.prefix', + kind: 'config', + aliases: ['prefix'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['files.routes.prefix'], + summary: 'Fixed URL prefix Devflare should mount discovered route modules under.', + detail: + 'Use this when the route tree should live under a mount point such as /api without changing the underlying route filenames.', + requirement: 'optional', + availableIn: 'files.routes config', + references: [docsReference('Routing', 'http-routing')] + }, + { + id: 'assets-directory', + label: 'assets.directory', + kind: 'config', + aliases: ['directory'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['assets.directory'], + summary: 'Directory of static assets to publish with the worker.', + detail: + 'Use this when the package ships compiled frontend assets or another static directory that should be part of the worker deployment contract.', + requirement: 'optional', + availableIn: 'assets config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + cloudflareReference( + 'Workers static assets', + 'https://developers.cloudflare.com/workers/static-assets/' + ) + ] + }, + { + id: 'assets-binding', + label: 'assets.binding', + kind: 'config', + aliases: ['binding'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['assets.binding'], + summary: 'Optional runtime binding name for the configured static assets.', + detail: + 'Use this when the assets directory should also be exposed through a named runtime binding instead of only by deployment behavior.', + requirement: 'optional', + availableIn: 'assets config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'routes-pattern', + label: 'routes.pattern', + kind: 'config', + aliases: ['pattern'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['routes.pattern'], + summary: 'Cloudflare route pattern that should send traffic to the worker.', + detail: + 'Use a normal route pattern such as api.example.com/* for path or wildcard routing. When the same route has custom_domain: true, the pattern must be a bare hostname such as api.example.com.', + requirement: 'optional', + availableIn: 'routes config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + cloudflareReference( + 'Workers routes', + 'https://developers.cloudflare.com/workers/configuration/routing/routes/' + ) + ] + }, + { + id: 'routes-custom-domain', + label: 'routes.custom_domain', + kind: 'config', + aliases: ['custom_domain'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['routes.custom_domain'], + summary: 'Mark the deployment route as a custom domain attachment.', + detail: + 'Use this when the Worker should be the origin for an entire hostname. Custom Domains do not allow wildcard operators or path segments in the pattern.', + requirement: 'optional', + availableIn: 'routes config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'ws-routes-pattern', + label: 'wsRoutes.pattern', + kind: 'config', + aliases: ['pattern'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['wsRoutes.pattern'], + summary: 'Local WebSocket path pattern that should be proxied into a Durable Object namespace.', + detail: + 'This pattern describes the incoming development URL shape before Devflare forwards the socket to the target Durable Object namespace.', + requirement: 'optional', + availableIn: 'wsRoutes config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'ws-routes-do-namespace', + label: 'wsRoutes.doNamespace', + kind: 'config', + aliases: ['donamespace'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['wsRoutes.doNamespace'], + summary: + 'Durable Object namespace binding that should receive the proxied WebSocket connection.', + detail: + 'Use the env binding name here so the WebSocket gateway knows which Durable Object namespace to target in development.', + requirement: 'optional', + availableIn: 'wsRoutes config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + docsReference('Durable Object binding guide', 'bindings/durable-objects') + ] + }, + { + id: 'ws-routes-id-param', + label: 'wsRoutes.idParam', + kind: 'config', + aliases: ['idparam'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['wsRoutes.idParam'], + summary: 'Route parameter name Devflare should read as the Durable Object identity.', + detail: + 'It defaults to id, but you can change it when the WebSocket path uses a different parameter name for object identity.', + defaultValue: 'id', + requirement: 'optional', + availableIn: 'wsRoutes config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'ws-routes-forward-path', + label: 'wsRoutes.forwardPath', + kind: 'config', + aliases: ['forwardpath'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['wsRoutes.forwardPath'], + summary: 'Path Devflare forwards to on the target Durable Object once the socket is proxied.', + detail: + 'Use this when the Durable Object expects its WebSocket upgrade on a path other than the default /websocket.', + defaultValue: '/websocket', + requirement: 'optional', + availableIn: 'wsRoutes config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'limits-cpu-ms', + label: 'limits.cpu_ms', + kind: 'config', + aliases: ['cpu_ms'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['limits.cpu_ms'], + summary: 'CPU time budget for the worker runtime.', + detail: + 'Use this when the package has an explicit CPU limit expectation that should stay visible in config review.', + requirement: 'optional', + availableIn: 'limits config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'observability-enabled', + label: 'observability.enabled', + kind: 'config', + aliases: ['enabled'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['observability.enabled'], + summary: 'Enable or disable the configured observability lane.', + detail: + 'Use this when tracing or logging posture should differ explicitly between environments instead of being implied somewhere later in deployment tooling.', + requirement: 'optional', + availableIn: 'observability config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + docsReference('Environments', 'config-environments') + ] + }, + { + id: 'observability-head-sampling-rate', + label: 'observability.head_sampling_rate', + kind: 'config', + aliases: ['head_sampling_rate'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['observability.head_sampling_rate'], + summary: 'Head-based sampling rate for observability collection.', + detail: + 'Use a value between 0 and 1 when the worker should explicitly sample only part of its traffic.', + requirement: 'optional', + availableIn: 'observability config', + references: [docsReference('Runtime & deploy settings', 'runtime-deploy-settings')] + }, + { + id: 'migrations-tag', + label: 'migrations.tag', + kind: 'config', + aliases: ['tag'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['migrations.tag'], + summary: 'Durable Object migration tag that names one release step.', + detail: + 'Keep migration tags explicit and ordered so the release history stays reviewable when Durable Object classes change over time.', + requirement: 'optional', + availableIn: 'migrations config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + cloudflareReference( + 'Durable Object migrations', + 'https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/' + ) + ] + }, + { + id: 'migrations-new-sqlite-classes', + label: 'migrations.new_sqlite_classes', + kind: 'config', + aliases: ['new_sqlite_classes'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['migrations.new_sqlite_classes'], + summary: 'Declare newly added SQLite-backed Durable Object classes in a migration step.', + detail: + 'Use this when a release introduces Durable Objects that should use the newer SQLite-backed storage model.', + requirement: 'optional', + availableIn: 'migrations config', + references: [ + docsReference('Runtime & deploy settings', 'runtime-deploy-settings'), + cloudflareReference( + 'Durable Object migrations', + 'https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/' + ) + ] + }, + { + id: 'wrangler-passthrough', + label: 'wrangler.passthrough', + kind: 'config', + aliases: ['passthrough'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['wrangler.passthrough'], + summary: 'Wrangler escape hatch for options Devflare does not model directly.', + detail: + 'Prefer first-class Devflare config keys when they exist. Use passthrough only for genuinely unsupported Wrangler options you still need to carry through.', + requirement: 'optional', + availableIn: 'wrangler config', + references: [docsReference('Config basics', 'config-basics')] + }, + { + id: 'rolldown-target', + label: 'rolldown.target', + kind: 'config', + aliases: ['target'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['rolldown.target'], + summary: "Target environment for Devflare's Rolldown-managed bundling lane.", + detail: + 'Use this when Durable Object or worker bundling should target an explicit JavaScript environment instead of inheriting the default.', + requirement: 'optional', + availableIn: 'rolldown config', + references: [docsReference('Svelte with Rolldown', 'svelte-with-rolldown')] + }, + { + id: 'rolldown-minify', + label: 'rolldown.minify', + kind: 'config', + aliases: ['minify'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['rolldown.minify'], + summary: 'Enable minification for Rolldown output.', + detail: + 'Use this when the bundler output should be minified as part of the Devflare-managed Rolldown lane.', + requirement: 'optional', + availableIn: 'rolldown config', + references: [docsReference('Svelte with Rolldown', 'svelte-with-rolldown')] + }, + { + id: 'rolldown-sourcemap', + label: 'rolldown.sourcemap', + kind: 'config', + aliases: ['sourcemap'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['rolldown.sourcemap'], + summary: 'Generate source maps for Rolldown output.', + detail: + "Use this when Devflare's bundler lane should emit source maps for debugging or inspection.", + requirement: 'optional', + availableIn: 'rolldown config', + references: [docsReference('Svelte with Rolldown', 'svelte-with-rolldown')] + }, + { + id: 'rolldown-options', + label: 'rolldown.options', + kind: 'config', + aliases: ['options'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['rolldown.options'], + summary: 'Raw Rolldown options object passed into the Devflare bundling lane.', + detail: + 'Reach for this when the high-level Rolldown settings are not enough and you need to pass additional raw bundler options through.', + requirement: 'optional', + availableIn: 'rolldown config', + references: [docsReference('Svelte with Rolldown', 'svelte-with-rolldown')] + }, + { + id: 'vite-plugins', + label: 'vite.plugins', + kind: 'config', + aliases: ['plugins'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['vite.plugins'], + summary: 'Devflare-level Vite plugin metadata from config.', + detail: + 'Use this for Devflare-aware Vite plugin coordination that belongs in devflare.config.ts rather than inside raw vite.config.* wiring.', + requirement: 'optional', + availableIn: 'vite config', + references: [docsReference('Vite standalone', 'vite-standalone')] + }, + { + id: 'bindings-kv', + label: 'bindings.kv', + kind: 'binding', + aliases: ['kv'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.kv'], + summary: 'Cloudflare Workers KV namespace bindings keyed by env name.', + detail: + 'Author KV bindings by stable namespace name or explicit resolver object. Devflare later resolves and compiles those values into Wrangler-friendly KV configuration.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('KV binding guide', 'bindings/kv'), + cloudflareReference('Workers KV docs', 'https://developers.cloudflare.com/kv/') + ] + }, + { + id: 'bindings-d1', + label: 'bindings.d1', + kind: 'binding', + aliases: ['d1'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.d1'], + summary: 'Cloudflare D1 database bindings keyed by env name.', + detail: + 'Like KV, Devflare prefers readable authoring by stable database name and only resolves concrete IDs when the workflow actually needs them.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('D1 binding guide', 'bindings/d1'), + cloudflareReference('D1 docs', 'https://developers.cloudflare.com/d1/') + ] + }, + { + id: 'bindings-r2', + label: 'bindings.r2', + kind: 'binding', + aliases: ['r2'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.r2'], + summary: 'Cloudflare R2 bucket bindings keyed by env name.', + detail: + 'Use R2 bindings when worker code should read or write bucket objects through the runtime env surface instead of hard-coding bucket names inside handlers.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('R2 binding guide', 'bindings/r2'), + cloudflareReference('R2 docs', 'https://developers.cloudflare.com/r2/') + ] + }, + { + id: 'bindings-durable-objects', + label: 'bindings.durableObjects', + kind: 'binding', + aliases: ['durableobjects'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + lineIncludes: ['{'], + lineExcludes: ['src/'], + propertyPathSuffixes: ['bindings.durableObjects'], + summary: + 'Durable Object namespace bindings that map env keys to class definitions or cross-worker refs.', + detail: + 'Bindings can use string shorthand, explicit className/scriptName objects, or ref()-based cross-worker wiring. Devflare normalizes them before type generation, local runtime, and compilation.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Durable Object binding guide', 'bindings/durable-objects'), + cloudflareReference( + 'Durable Objects docs', + 'https://developers.cloudflare.com/durable-objects/' + ) + ] + }, + { + id: 'bindings-queues', + label: 'bindings.queues', + kind: 'binding', + aliases: ['queues'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.queues'], + summary: 'Queue producer and consumer configuration for Cloudflare Queues.', + detail: + 'Producers live on env like other bindings, while consumers are declared in config so Devflare can wire queue handlers and retry behavior honestly.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Queue binding guide', 'bindings/queues'), + cloudflareReference('Queues docs', 'https://developers.cloudflare.com/queues/') + ] + }, + { + id: 'bindings-queue-producers', + label: 'queues.producers', + kind: 'binding', + aliases: ['producers'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['queues:'], + propertyPathSuffixes: ['bindings.queues.producers'], + summary: 'Queue producer bindings that map an env key to a queue name.', + detail: + 'Use producers when worker code should enqueue messages by calling an env binding rather than hard-coding queue names in the handler body.', + requirement: 'optional', + availableIn: 'bindings.queues', + references: [ + docsReference('Queue binding guide', 'bindings/queues'), + cloudflareReference( + 'Queues producers', + 'https://developers.cloudflare.com/queues/get-started/' + ) + ] + }, + { + id: 'bindings-queue-consumers', + label: 'queues.consumers', + kind: 'binding', + aliases: ['consumers'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['queues:'], + propertyPathSuffixes: ['bindings.queues.consumers'], + summary: 'Queue consumer definitions that control batching, retries, and DLQ behavior.', + detail: + 'Each consumer describes the queue it reads, optional batch settings, retry limits, and dead-letter routing so the config stays explicit about operational behavior.', + requirement: 'optional', + availableIn: 'bindings.queues', + references: [ + docsReference('Queue binding guide', 'bindings/queues'), + cloudflareReference( + 'Queues consumers', + 'https://developers.cloudflare.com/queues/configuration/javascript-apis/' + ) + ] + }, + { + id: 'bindings-services', + label: 'bindings.services', + kind: 'binding', + aliases: ['services'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.services'], + summary: 'Service bindings that point one worker at another worker or named entrypoint.', + detail: + 'Services pair naturally with ref() so Devflare can resolve the worker family, generate env types, and keep local multi-worker tests aligned with the actual runtime relationship.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Service binding guide', 'bindings/services'), + cloudflareReference( + 'Service bindings docs', + 'https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/' + ) + ] + }, + { + id: 'bindings-ai', + label: 'bindings.ai', + kind: 'binding', + aliases: ['ai'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.ai'], + summary: 'Workers AI binding configuration for remote inference access.', + detail: + 'AI is remote-oriented, so the docs emphasis is on explicit account context, preview truthfulness, and being honest about when tests are using a real remote model.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('AI binding guide', 'bindings/ai'), + cloudflareReference('Workers AI docs', 'https://developers.cloudflare.com/workers-ai/') + ] + }, + { + id: 'bindings-vectorize', + label: 'bindings.vectorize', + kind: 'binding', + aliases: ['vectorize'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.vectorize'], + summary: 'Vectorize index bindings for similarity search or embedding retrieval flows.', + detail: + 'Use this when a worker should talk to a Vectorize index through a typed env binding instead of sprinkling raw index identifiers through source code.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Vectorize binding guide', 'bindings/vectorize'), + cloudflareReference('Vectorize docs', 'https://developers.cloudflare.com/vectorize/') + ] + }, + { + id: 'bindings-hyperdrive', + label: 'bindings.hyperdrive', + kind: 'binding', + aliases: ['hyperdrive'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.hyperdrive'], + summary: 'Hyperdrive bindings for accelerated PostgreSQL access through Cloudflare.', + detail: + 'Author by readable configuration name when possible and let Devflare resolve IDs later, which keeps source review easier than pinning opaque IDs everywhere.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Hyperdrive binding guide', 'bindings/hyperdrive'), + cloudflareReference('Hyperdrive docs', 'https://developers.cloudflare.com/hyperdrive/') + ] + } +] diff --git a/apps/documentation/src/lib/intellisense/registry/definitions-3.ts b/apps/documentation/src/lib/intellisense/registry/definitions-3.ts new file mode 100644 index 0000000..669be7f --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry/definitions-3.ts @@ -0,0 +1,650 @@ +import { docPath, getCanonicalDocSlug } from '$lib/docs/content' +import type { IntellisenseDefinition, IntellisenseLink } from '../types' +import { configFilePattern } from './shared' + +function docsReference(label: string, slug: string): IntellisenseLink { + return { + label, + href: docPath(getCanonicalDocSlug(slug) ?? slug) + } +} + +function cloudflareReference(label: string, href: string): IntellisenseLink { + return { + label, + href, + external: true, + citation: 'Cloudflare Docs' + } +} + +export const definitionsPart3: IntellisenseDefinition[] = [ + { + id: 'bindings-browser', + label: 'bindings.browser', + kind: 'binding', + aliases: ['browser'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.browser'], + summary: 'Browser Rendering binding for headless browser sessions.', + detail: + 'Devflare currently supports exactly one browser binding because Wrangler does too. The binding becomes the env value passed into tools such as @cloudflare/puppeteer.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Browser binding guide', 'bindings/browser-rendering'), + cloudflareReference( + 'Browser Rendering docs', + 'https://developers.cloudflare.com/browser-rendering/' + ) + ] + }, + { + id: 'bindings-analytics-engine', + label: 'bindings.analyticsEngine', + kind: 'binding', + aliases: ['analyticsengine'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.analyticsEngine'], + summary: 'Analytics Engine dataset bindings for structured event writes.', + detail: + 'Use this when the worker should write analytics events to a named dataset through the env surface instead of constructing the dataset identity ad hoc.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('Analytics Engine binding guide', 'bindings/analytics-engine'), + cloudflareReference( + 'Analytics Engine docs', + 'https://developers.cloudflare.com/analytics/analytics-engine/' + ) + ] + }, + { + id: 'bindings-send-email', + label: 'bindings.sendEmail', + kind: 'binding', + aliases: ['sendemail'], + contexts: ['config'], + filePatterns: [configFilePattern], + codeIncludes: ['bindings:'], + propertyPathSuffixes: ['bindings.sendEmail'], + summary: 'send_email bindings for Email Workers outbound mail flows.', + detail: + 'Use this when worker code should send email through a verified binding. The config can restrict destinations or sender addresses so the contract stays explicit in source.', + requirement: 'optional', + availableIn: 'bindings section of devflare config', + references: [ + docsReference('sendEmail binding guide', 'bindings/send-email'), + cloudflareReference( + 'send_email docs', + 'https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/' + ) + ] + }, + { + id: 'queue-consumer-queue', + label: 'queues.consumers.queue', + kind: 'binding', + aliases: ['queue'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.queue'], + summary: 'Queue name that this consumer definition reads from.', + detail: + 'Keep this explicit so batching, retries, and dead-letter behavior stay attached to one clearly named queue.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-dead-letter-queue', + label: 'queues.consumers.deadLetterQueue', + kind: 'binding', + aliases: ['deadletterqueue'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.deadLetterQueue'], + summary: 'Queue that should receive messages after retries are exhausted.', + detail: + 'Use this when failed messages should be retained for inspection or reprocessing instead of being dropped after the retry limit.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-max-batch-size', + label: 'queues.consumers.maxBatchSize', + kind: 'binding', + aliases: ['maxbatchsize'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.maxBatchSize'], + summary: 'Maximum number of messages delivered per consumer batch.', + detail: + 'Use this when the consumer should balance throughput against per-batch work cost or latency.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-max-batch-timeout', + label: 'queues.consumers.maxBatchTimeout', + kind: 'binding', + aliases: ['maxbatchtimeout'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.maxBatchTimeout'], + summary: 'Maximum seconds Cloudflare should wait while collecting a batch.', + detail: + 'Use this when the consumer should flush smaller batches sooner instead of waiting longer for a fuller one.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-max-retries', + label: 'queues.consumers.maxRetries', + kind: 'binding', + aliases: ['maxretries'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.maxRetries'], + summary: 'Maximum retry attempts before a message is treated as failed.', + detail: + 'Use this when the consumer should explicitly cap retry behavior instead of relying on vague operational assumptions.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-max-concurrency', + label: 'queues.consumers.maxConcurrency', + kind: 'binding', + aliases: ['maxconcurrency'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.maxConcurrency'], + summary: 'Maximum concurrent consumer invocations for this queue definition.', + detail: + 'Use this when parallelism should stay capped for downstream systems, database pressure, or other operational constraints.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'queue-consumer-retry-delay', + label: 'queues.consumers.retryDelay', + kind: 'binding', + aliases: ['retrydelay'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.queues.consumers.retryDelay'], + summary: 'Delay in seconds between retry attempts.', + detail: + 'Use this when failed work should back off explicitly instead of retrying immediately under the same load conditions.', + requirement: 'optional', + availableIn: 'bindings.queues.consumers', + references: [docsReference('Queue binding guide', 'bindings/queues')] + }, + { + id: 'service-binding-service', + label: 'bindings.services.*.service', + kind: 'binding', + aliases: ['service'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.services.*.service'], + summary: 'Target worker service name for a service binding.', + detail: + 'Use the worker name here when a binding should point at another worker without relying on implicit naming or ad hoc fetch URLs.', + requirement: 'optional', + availableIn: 'bindings.services', + references: [docsReference('Service binding guide', 'bindings/services')] + }, + { + id: 'ai-binding-binding', + label: 'bindings.ai.binding', + kind: 'binding', + aliases: ['binding'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPaths: ['bindings.ai.binding'], + summary: 'Runtime env binding name for the Workers AI integration.', + detail: 'Use this when the AI binding should appear on env under an explicit name such as AI.', + requirement: 'optional', + availableIn: 'bindings.ai', + references: [docsReference('AI binding guide', 'bindings/ai')] + }, + { + id: 'vectorize-index-name', + label: 'bindings.vectorize.*.indexName', + kind: 'binding', + aliases: ['indexname'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.vectorize.*.indexName'], + summary: 'Backing Vectorize index name for this binding.', + detail: + 'Use a stable index name here so the worker binding stays readable while the real Vectorize target remains explicit.', + requirement: 'optional', + availableIn: 'bindings.vectorize', + references: [docsReference('Vectorize binding guide', 'bindings/vectorize')] + }, + { + id: 'analytics-dataset', + label: 'bindings.analyticsEngine.*.dataset', + kind: 'binding', + aliases: ['dataset'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.analyticsEngine.*.dataset'], + summary: 'Analytics Engine dataset name for this binding.', + detail: + 'Use the dataset name here so the worker writes to an explicit Analytics Engine dataset through the env surface.', + requirement: 'optional', + availableIn: 'bindings.analyticsEngine', + references: [docsReference('Analytics Engine binding guide', 'bindings/analytics-engine')] + }, + { + id: 'send-email-destination-address', + label: 'bindings.sendEmail.*.destinationAddress', + kind: 'binding', + aliases: ['destinationaddress'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['bindings.sendEmail.*.destinationAddress'], + summary: 'Single verified destination address this sendEmail binding may target.', + detail: + 'Use this when outbound mail should be constrained to one verified destination instead of a broader allowed list.', + requirement: 'optional', + availableIn: 'bindings.sendEmail', + references: [docsReference('sendEmail binding guide', 'bindings/send-email')] + }, + { + id: 'secret-required', + label: 'secrets.*.required', + kind: 'config', + aliases: ['required'], + contexts: ['config'], + filePatterns: [configFilePattern], + propertyPathSuffixes: ['secrets.*.required'], + summary: 'Declare whether a configured secret is required by default.', + detail: + 'Use this when the secret declaration itself should say whether the value is expected to exist, rather than leaving that expectation implicit.', + defaultValue: 'true', + requirement: 'optional', + availableIn: 'secrets config', + references: [docsReference('Config basics', 'config-basics')] + }, + { + id: 'runtime-fetch-event', + label: 'FetchEvent', + kind: 'runtime', + aliases: ['fetchevent'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Typed event object for HTTP fetch handlers.', + detail: + 'Use this at the handler boundary when you want request, env, ctx, params, and locals on the real Devflare request surface instead of a generic WorkerRequest guess.', + requirement: 'contextual', + availableIn: 'HTTP handlers and middleware', + references: [ + docsReference('First worker', 'first-worker'), + docsReference('Runtime context', 'runtime-context') + ] + }, + { + id: 'runtime-queue-event', + label: 'QueueEvent', + kind: 'runtime', + aliases: ['queueevent'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Typed event object for queue consumer handlers.', + detail: + 'QueueEvent exposes event.messages and the rest of the worker context in the same event-first style Devflare uses for fetch handlers.', + requirement: 'contextual', + availableIn: 'Queue consumer handlers', + references: [ + docsReference('Queue binding guide', 'bindings/queues'), + cloudflareReference('Queues docs', 'https://developers.cloudflare.com/queues/') + ] + }, + { + id: 'runtime-scheduled-event', + label: 'ScheduledEvent', + kind: 'runtime', + aliases: ['scheduledevent'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Typed event object for cron and scheduled handlers.', + detail: + 'Use this when scheduled work needs the cron string, scheduledTime, env, and execution context in one runtime-shaped object.', + requirement: 'contextual', + availableIn: 'Scheduled handlers', + references: [ + docsReference('Runtime context', 'runtime-context'), + cloudflareReference( + 'Cron triggers', + 'https://developers.cloudflare.com/workers/configuration/cron-triggers/' + ) + ] + }, + { + id: 'runtime-resolve-fetch', + label: 'ResolveFetch', + kind: 'runtime', + aliases: ['resolvefetch'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'The continuation function passed into sequence() middleware.', + detail: + 'Calling resolve(event) advances to the next middleware or matched route/module handler, which is what makes sequence() explicit rather than magical.', + requirement: 'contextual', + availableIn: 'sequence() middleware', + references: [ + docsReference('sequence(...) middleware', 'sequence-middleware'), + docsReference('Runtime context', 'runtime-context') + ] + }, + { + id: 'runtime-env-proxy', + label: 'env', + kind: 'runtime', + aliases: ['env'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: + 'Readonly request-scoped proxy for bindings and variables in the active Devflare handler trail.', + detail: + 'Use this when you are already inside a Devflare-managed request or job and want typed access to KV, D1, vars, services, or other bindings without threading env through every helper manually.', + requirement: 'contextual', + availableIn: 'Active Devflare runtime context', + references: [ + docsReference('Runtime context', 'runtime-context'), + docsReference('First bindings', 'first-bindings') + ] + }, + { + id: 'unified-env-proxy', + label: 'env', + kind: 'env', + aliases: ['env'], + contexts: ['runtime', 'test'], + codeIncludes: ["from 'devflare'"], + summary: 'Unified env proxy that works in handlers, tests, and local bridge-backed flows.', + detail: + 'It tries request context first, then test context, then the local bridge. That is why createTestContext() plus env.dispose() is the default Devflare test loop.', + requirement: 'contextual', + availableIn: 'Worker code and tests', + references: [ + docsReference('createTestContext()', 'create-test-context'), + docsReference('Runtime context', 'runtime-context') + ] + }, + { + id: 'runtime-ctx-proxy', + label: 'ctx', + kind: 'runtime', + aliases: ['ctx'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Readonly execution-context proxy for waitUntil() and related background-work hooks.', + detail: + 'In fetch handlers this exposes ExecutionContext-like behavior. In Durable Object trails it resolves to the current DurableObjectState instead.', + requirement: 'contextual', + availableIn: 'Active Devflare runtime context', + references: [docsReference('Runtime context', 'runtime-context')] + }, + { + id: 'runtime-locals', + label: 'locals', + kind: 'runtime', + aliases: ['locals'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Mutable request-scoped storage shared across middleware and handlers.', + detail: + 'Unlike env, ctx, and the runtime event proxy, locals is intentionally mutable. Use it for auth state or computed request data that downstream code should read later in the same trail.', + requirement: 'contextual', + availableIn: 'Active Devflare runtime context', + references: [ + docsReference('Runtime context', 'runtime-context'), + docsReference('sequence(...) middleware', 'sequence-middleware') + ] + }, + { + id: 'runtime-sequence', + label: 'sequence()', + kind: 'runtime', + aliases: ['sequence'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Request-wide middleware composer for explicit top-to-bottom HTTP flow.', + detail: + 'Use sequence() for broad concerns such as auth, CORS, logging, or request IDs that should wrap route resolution rather than being copied into every leaf handler.', + requirement: 'contextual', + availableIn: 'HTTP middleware code', + references: [ + docsReference('sequence(...) middleware', 'sequence-middleware'), + docsReference('SvelteKit with Devflare', 'sveltekit-with-devflare') + ] + }, + { + id: 'runtime-get-fetch-event', + label: 'getFetchEvent()', + kind: 'runtime', + aliases: ['getfetchevent'], + contexts: ['runtime'], + codeIncludes: ['devflare/runtime'], + summary: 'Getter for the active fetch event deeper in the same AsyncLocalStorage-backed trail.', + detail: + 'Prefer explicit event parameters at the handler boundary, then use getFetchEvent() or getFetchEvent.safe() deeper in helpers when plumbing the event through every function would be ceremony.', + requirement: 'contextual', + availableIn: 'Helpers called inside fetch trails', + references: [docsReference('Runtime context', 'runtime-context')] + }, + { + id: 'test-create-test-context', + label: 'createTestContext()', + kind: 'test', + aliases: ['createtestcontext'], + contexts: ['test'], + codeIncludes: ['devflare/test'], + summary: 'Default runtime-shaped test harness for Devflare projects.', + detail: + 'It discovers the nearest supported config, starts the local runtime, wires the configured bindings, and gives tests the same shapes the worker uses for real.', + requirement: 'contextual', + availableIn: 'Bun tests', + references: [ + docsReference('createTestContext()', 'create-test-context'), + docsReference('Testing and automation', 'testing-and-automation') + ] + }, + { + id: 'test-cf-helper', + label: 'cf', + kind: 'test', + aliases: ['cf'], + contexts: ['test'], + codeIncludes: ['devflare/test'], + summary: + 'Unified helper surface for triggering worker, queue, email, scheduled, and tail handlers in tests.', + detail: + 'Use cf.* when the test should talk to the runtime like a real caller would, instead of poking implementation details directly.', + requirement: 'contextual', + availableIn: 'Bun tests with createTestContext()', + references: [ + docsReference('createTestContext()', 'create-test-context'), + docsReference('Testing and automation', 'testing-and-automation') + ] + }, + { + id: 'preview-env-branch', + label: 'DEVFLARE_PREVIEW_BRANCH', + kind: 'env', + aliases: ['devflare_preview_branch'], + contexts: ['shell', 'yaml'], + summary: 'Environment hint that tells Devflare which preview branch identifier to materialize.', + detail: + 'Named preview flows use this when branch metadata should become the preview identifier for scoped resources and worker names.', + requirement: 'contextual', + availableIn: 'Preview automation and deploy scripts', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'preview-env-identifier', + label: 'DEVFLARE_PREVIEW_IDENTIFIER', + kind: 'env', + aliases: ['devflare_preview_identifier'], + contexts: ['shell', 'yaml'], + summary: 'Explicit preview identifier override for preview-scoped naming.', + detail: + 'If this is set, Devflare uses it before falling back to PR or branch-derived preview identifiers.', + requirement: 'contextual', + availableIn: 'Preview automation and deploy scripts', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'preview-env-pr', + label: 'DEVFLARE_PREVIEW_PR', + kind: 'env', + aliases: ['devflare_preview_pr'], + contexts: ['shell', 'yaml'], + summary: 'PR-number hint for preview scope naming.', + detail: + 'When set, Devflare normalizes this into a preview identifier like pr-123 before materializing preview-scoped names.', + requirement: 'contextual', + availableIn: 'Preview automation and deploy scripts', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'cli-devflare', + label: 'devflare', + kind: 'cli', + aliases: ['devflare'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: + 'Devflare CLI for local development, builds, deploys, types, config inspection, and preview operations.', + detail: + 'Commands resolve your local Devflare config first, then bridge that config into Wrangler-compatible workflows so the CLI vocabulary stays stable across local and deploy lanes.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [ + docsReference('Devflare CLI', 'devflare-cli'), + docsReference('Production deploys', 'production-deploys') + ] + }, + { + id: 'cli-dev-command', + label: 'devflare dev', + kind: 'cli', + aliases: ['dev'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: + 'Start local development with worker-only mode by default and Vite when an effective local Vite app exists.', + detail: + 'Devflare watches worker and Durable Object source files, rebuilds them as needed, and mirrors the runtime shape the app will use in real workflows.', + requirement: 'contextual', + availableIn: 'Terminal commands and package scripts', + references: [ + docsReference('Devflare CLI', 'devflare-cli'), + docsReference('Vite standalone', 'vite-standalone') + ] + }, + { + id: 'cli-build-command', + label: 'devflare build', + kind: 'cli', + aliases: ['build'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: 'Build deployment artifacts from the effective Devflare config.', + detail: + 'This resolves env overrides first, prepares the Wrangler-facing output, and lets you inspect the deployment contract before actually shipping it.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [ + docsReference('Devflare CLI', 'devflare-cli'), + docsReference('Production deploys', 'production-deploys') + ] + }, + { + id: 'cli-deploy-command', + label: 'devflare deploy', + kind: 'cli', + aliases: ['deploy'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: 'Deploy explicitly to production or preview targets.', + detail: + 'Devflare rejects ambiguous deploys from the CLI so production and preview intent stay unmistakable. Named preview deploys can also provision preview-scoped resources automatically.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [ + docsReference('Production deploys', 'production-deploys'), + docsReference('Preview strategies', 'preview-strategies') + ] + }, + { + id: 'cli-types-command', + label: 'devflare types', + kind: 'cli', + aliases: ['types'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: 'Generate env.d.ts-style bindings and entrypoint-aware types from config.', + detail: + 'Re-run this whenever bindings, Durable Objects, or service entrypoints change so the generated env surface stays honest.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [ + docsReference('Devflare CLI', 'devflare-cli'), + docsReference('First bindings', 'first-bindings') + ] + }, + { + id: 'cli-doctor-command', + label: 'devflare doctor', + kind: 'cli', + aliases: ['doctor'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: + 'Project diagnostics for config, TypeScript, framework integration, and generated artifacts.', + detail: + 'Use this when the project feels broken in a vague and unhelpful way. It checks whether the expected Devflare pieces are present and loadable.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [docsReference('Devflare CLI', 'devflare-cli')] + }, + { + id: 'cli-config-command', + label: 'devflare config', + kind: 'cli', + aliases: ['config'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare'], + summary: 'Print the resolved Devflare config or compiled Wrangler JSON.', + detail: + 'Use this when you want to inspect the exact configuration Devflare sees after env resolution instead of guessing how authored config becomes deploy output.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [docsReference('Devflare CLI', 'devflare-cli')] + } +] diff --git a/apps/documentation/src/lib/intellisense/registry/definitions-4.ts b/apps/documentation/src/lib/intellisense/registry/definitions-4.ts new file mode 100644 index 0000000..c8b09d7 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry/definitions-4.ts @@ -0,0 +1,184 @@ +import { docPath, getCanonicalDocSlug } from '$lib/docs/content' +import type { IntellisenseDefinition, IntellisenseLink } from '../types' + +function docsReference(label: string, slug: string): IntellisenseLink { + return { + label, + href: docPath(getCanonicalDocSlug(slug) ?? slug) + } +} + +function cloudflareReference(label: string, href: string): IntellisenseLink { + return { + label, + href, + external: true, + citation: 'Cloudflare Docs' + } +} + +export const definitionsPart4: IntellisenseDefinition[] = [ + { + id: 'cli-previews-command', + label: 'devflare previews', + kind: 'cli', + aliases: ['previews'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare previews'], + summary: 'Inspect preview scopes, preview resources, and current preview registry state.', + detail: + 'Use this when preview infrastructure already exists and you need to inspect or clean it up instead of only deploying a new preview.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [ + docsReference('Preview operations', 'preview-operations'), + docsReference('Preview strategies', 'preview-strategies') + ] + }, + { + id: 'cli-productions-command', + label: 'devflare productions', + kind: 'cli', + aliases: ['productions'], + contexts: ['shell', 'yaml'], + codeIncludes: ['devflare productions'], + summary: 'Inspect and manage live production workers and deployments.', + detail: + 'This is the production-side inspection lane when you need to see what is live instead of only reasoning from local build output.', + requirement: 'contextual', + availableIn: 'Terminal commands and automation scripts', + references: [docsReference('Production deploys', 'production-deploys')] + }, + { + id: 'cli-flag-env', + label: '--env', + kind: 'flag', + aliases: ['--env'], + contexts: ['shell', 'yaml'], + summary: 'Resolve config.env[name] before building, printing, or deploying.', + detail: + 'Use this when a named environment should be applied on top of the base config. It is especially useful for build and config inspection flows.', + requirement: 'contextual', + availableIn: 'build, deploy, and config commands', + references: [ + docsReference('Devflare CLI', 'devflare-cli'), + docsReference('Config basics', 'config-basics') + ] + }, + { + id: 'cli-flag-preview', + label: '--preview', + kind: 'flag', + aliases: ['--preview'], + contexts: ['shell', 'yaml'], + summary: 'Select preview deployment mode, optionally with a named preview scope.', + detail: + 'Pass a value such as next or pr-1 for named preview scopes, or omit the value for a same-worker preview upload.', + requirement: 'contextual', + availableIn: 'deploy command', + references: [ + docsReference('Preview strategies', 'preview-strategies'), + docsReference('Preview operations', 'preview-operations') + ] + }, + { + id: 'cli-flag-prod', + label: '--prod', + kind: 'flag', + aliases: ['--prod'], + contexts: ['shell', 'yaml'], + summary: 'Explicitly target a production deployment.', + detail: + 'Devflare requires an explicit production or preview target at deploy time so production intent is never accidental.', + requirement: 'contextual', + availableIn: 'deploy command', + references: [docsReference('Production deploys', 'production-deploys')] + }, + { + id: 'cli-flag-production', + label: '--production', + kind: 'flag', + aliases: ['--production'], + contexts: ['shell', 'yaml'], + summary: 'Long-form alias for --prod.', + detail: + 'Use this when you want the longer spelling in CI or scripts, but the deploy behavior is the same as --prod.', + requirement: 'contextual', + availableIn: 'deploy command', + references: [docsReference('Production deploys', 'production-deploys')] + }, + { + id: 'cli-flag-dry-run', + label: '--dry-run', + kind: 'flag', + aliases: ['--dry-run'], + contexts: ['shell', 'yaml'], + summary: 'Print the synthesized deployment config and skip the actual remote operation.', + detail: + 'Use this when you want to review the exact deploy contract before making a real production or preview change.', + requirement: 'contextual', + availableIn: 'deploy command', + references: [ + docsReference('Production deploys', 'production-deploys'), + docsReference('Preview strategies', 'preview-strategies') + ] + }, + { + id: 'cli-flag-config', + label: '--config', + kind: 'flag', + aliases: ['--config'], + contexts: ['shell', 'yaml'], + summary: 'Use a specific devflare config path instead of default config resolution.', + detail: + 'This is useful in monorepos or automation when the working directory is not already the package that owns the intended config file.', + requirement: 'contextual', + availableIn: 'Most CLI commands', + references: [docsReference('Devflare CLI', 'devflare-cli')] + }, + { + id: 'cli-flag-output', + label: '--output', + kind: 'flag', + aliases: ['--output'], + contexts: ['shell', 'yaml'], + summary: 'Write generated output to a custom location.', + detail: + 'In the types command, this changes where the generated env.d.ts-style bindings file is written instead of using the default path.', + requirement: 'contextual', + availableIn: 'types command', + references: [docsReference('Devflare CLI', 'devflare-cli')] + }, + { + id: 'cli-flag-debug', + label: '--debug', + kind: 'flag', + aliases: ['--debug'], + contexts: ['shell', 'yaml'], + summary: 'Enable extra stack traces and debug logging when a command fails.', + detail: + 'Reach for this when the normal command output is too polite to explain what actually went wrong.', + requirement: 'contextual', + availableIn: 'Many CLI commands', + references: [docsReference('Devflare CLI', 'devflare-cli')] + }, + { + id: 'browser-puppeteer', + label: '@cloudflare/puppeteer', + kind: 'binding', + aliases: ['@cloudflare/puppeteer'], + contexts: ['runtime', 'test'], + summary: 'Cloudflare-maintained Puppeteer integration for Browser Rendering sessions.', + detail: + 'Use this with a browser binding when worker code should launch or control a Cloudflare Browser Rendering session through a familiar Puppeteer API.', + requirement: 'contextual', + availableIn: 'Browser Rendering examples', + references: [ + docsReference('Browser binding guide', 'bindings/browser-rendering'), + cloudflareReference( + 'Browser Rendering docs', + 'https://developers.cloudflare.com/browser-rendering/' + ) + ] + } +] diff --git a/apps/documentation/src/lib/intellisense/registry/shared.ts b/apps/documentation/src/lib/intellisense/registry/shared.ts new file mode 100644 index 0000000..f8c5ff3 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/registry/shared.ts @@ -0,0 +1 @@ +export const configFilePattern = /(^|[/\\])devflare\.config\.(ts|mts|js|mjs)$/i diff --git a/apps/documentation/src/lib/intellisense/types.ts b/apps/documentation/src/lib/intellisense/types.ts new file mode 100644 index 0000000..f544ad9 --- /dev/null +++ b/apps/documentation/src/lib/intellisense/types.ts @@ -0,0 +1,59 @@ +export type IntellisenseKind = + | 'module' + | 'config' + | 'binding' + | 'runtime' + | 'test' + | 'cli' + | 'flag' + | 'env' + +export type IntellisenseRequirement = 'required' | 'optional' | 'contextual' + +export type IntellisenseContextTag = + | 'config' + | 'runtime' + | 'test' + | 'shell' + | 'yaml' + | 'json' + | 'unknown' + +export interface IntellisenseLink { + label: string + href: string + external?: boolean + citation?: string +} + +export interface IntellisenseEntry { + id: string + label: string + kind: IntellisenseKind + summary: string + detail?: string + defaultValue?: string + requirement?: IntellisenseRequirement + availableIn?: string + references?: IntellisenseLink[] +} + +export interface IntellisenseRenderContext { + language: string + filePath?: string + code: string + lineText?: string + tokenType?: string + propertyPath?: string +} + +export interface IntellisenseDefinition extends IntellisenseEntry { + aliases: string[] + contexts?: IntellisenseContextTag[] + filePatterns?: RegExp[] + codeIncludes?: string[] + lineIncludes?: string[] + lineExcludes?: string[] + propertyPaths?: string[] + propertyPathSuffixes?: string[] +} diff --git a/apps/documentation/src/lib/site/social.ts b/apps/documentation/src/lib/site/social.ts new file mode 100644 index 0000000..07ca332 --- /dev/null +++ b/apps/documentation/src/lib/site/social.ts @@ -0,0 +1,31 @@ +import type { DocPage } from '$lib/docs/types' + +type SocialDoc = Pick + +export const BRAND_COLOR = '#ff5000' +export const SOCIAL_CARDS_PATH = '/social-cards' +export const DEFAULT_SOCIAL_CARD_TITLE = 'Cloudflare Workers without the glue work' +export const SOCIAL_IMAGE_ALT = 'Devflare Docs social preview card.' +export const DEFAULT_SOCIAL_TITLE = 'Devflare Docs' +export const DEFAULT_SOCIAL_DESCRIPTION = + 'Build and test Cloudflare Workers with local-first bindings, typed config, preview workflows, and examples you can run.' + +export function getSocialTitle(doc: SocialDoc | undefined): string { + return doc ? `${doc.navTitle} - Devflare Docs` : DEFAULT_SOCIAL_TITLE +} + +export function getSocialDescription(doc: SocialDoc | undefined): string { + return doc?.summary ?? DEFAULT_SOCIAL_DESCRIPTION +} + +export function getSocialImageAlt(doc: SocialDoc | undefined): string { + return doc ? `Devflare Docs preview for ${doc.navTitle}.` : SOCIAL_IMAGE_ALT +} + +export function getSocialCardPath(doc: (SocialDoc & { slug: string }) | undefined): string { + return doc ? `${SOCIAL_CARDS_PATH}/docs/${doc.slug}.png` : `${SOCIAL_CARDS_PATH}/home.png` +} + +export function toAbsoluteUrl(origin: string, path: string): string { + return new URL(path, origin).toString() +} diff --git a/apps/documentation/src/lib/social-card/SocialCard.svelte b/apps/documentation/src/lib/social-card/SocialCard.svelte new file mode 100644 index 0000000..fe9dd7d --- /dev/null +++ b/apps/documentation/src/lib/social-card/SocialCard.svelte @@ -0,0 +1,271 @@ + + + + + + + diff --git a/apps/documentation/src/lib/vendor/floating-runes.ts b/apps/documentation/src/lib/vendor/floating-runes.ts new file mode 100644 index 0000000..77c99e9 --- /dev/null +++ b/apps/documentation/src/lib/vendor/floating-runes.ts @@ -0,0 +1,33 @@ +import type { Action } from 'svelte/action' + +export interface FloatingRunesOptions { + placement?: string + strategy?: 'absolute' | 'fixed' + middleware?: unknown[] + autoPosition?: boolean +} + +export type FloatingRunesAction = Action & { + ref: Action + arrow: Action +} + +// @ts-ignore VS Code's Svelte language service can miss Bun-hoisted package metadata here, +// but the dependency is installed and resolves correctly in check/build. +import floatingUIUntyped, { + createSingleton as createSingletonUntyped, + flip as flipUntyped, + offset as offsetUntyped, + portal as portalUntyped, + shift as shiftUntyped +} from 'floating-runes' + +const floatingUI = floatingUIUntyped as (options?: FloatingRunesOptions) => FloatingRunesAction + +export const createSingleton = createSingletonUntyped +export const flip = flipUntyped +export const offset = offsetUntyped +export const portal = portalUntyped +export const shift = shiftUntyped + +export default floatingUI diff --git a/apps/documentation/src/lib/vendor/pretext.ts b/apps/documentation/src/lib/vendor/pretext.ts new file mode 100644 index 0000000..fd0441e --- /dev/null +++ b/apps/documentation/src/lib/vendor/pretext.ts @@ -0,0 +1,33 @@ +export interface PreparedText { } + +export interface PreparedTextWithSegments extends PreparedText { } + +export interface LayoutResult { + lineCount: number + height: number +} + +export interface PretextModule { + prepare( + text: string, + font: string, + options?: { + whiteSpace?: 'normal' | 'pre-wrap' + wordBreak?: 'normal' | 'keep-all' + } + ): PreparedText + prepareWithSegments( + text: string, + font: string, + options?: { + whiteSpace?: 'normal' | 'pre-wrap' + wordBreak?: 'normal' | 'keep-all' + } + ): PreparedTextWithSegments + layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult + measureNaturalWidth(prepared: PreparedTextWithSegments): number +} + +export async function loadPretext(): Promise { + return import('@chenglou/pretext') as Promise +} diff --git a/apps/documentation/src/routes/+layout.svelte b/apps/documentation/src/routes/+layout.svelte new file mode 100644 index 0000000..2560f25 --- /dev/null +++ b/apps/documentation/src/routes/+layout.svelte @@ -0,0 +1,381 @@ + + + + + {socialTitle} + + + + + + + + + + + + + + + + + + + + + + +{#if sidebarOpen} + +{/if} + + +
+ + +
+
+ + + + {m.site_title()} + +
+ +
+ + +
+ {@render children()} +
+
+
+ + + +
diff --git a/apps/documentation/src/routes/+page.svelte b/apps/documentation/src/routes/+page.svelte new file mode 100644 index 0000000..c9767c1 --- /dev/null +++ b/apps/documentation/src/routes/+page.svelte @@ -0,0 +1,138 @@ + + +
+ +
+
+ + +
+ +
+ +

+ + + {m.home_hero_support_link()} + +

+ +
+ {#each heroHighlights as highlight} + + + + + {/each} +
+
+ +
+ +

+ +

+
+
+
+ +
+ + +
+ {#each libraryFeatures as feature} + + {/each} +
+
+ + +
diff --git a/apps/documentation/src/routes/LLM.md/+server.ts b/apps/documentation/src/routes/LLM.md/+server.ts new file mode 100644 index 0000000..96ae43f --- /dev/null +++ b/apps/documentation/src/routes/LLM.md/+server.ts @@ -0,0 +1,9 @@ +import { createLLMDocumentResponse } from '$lib/docs/llm-response' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = () => { + return createLLMDocumentResponse({ + contentType: 'text/markdown; charset=utf-8', + variant: 'full' + }) +} \ No newline at end of file diff --git a/apps/documentation/src/routes/LLM.txt/+server.ts b/apps/documentation/src/routes/LLM.txt/+server.ts new file mode 100644 index 0000000..aa84da8 --- /dev/null +++ b/apps/documentation/src/routes/LLM.txt/+server.ts @@ -0,0 +1,9 @@ +import { createLLMDocumentResponse } from '$lib/docs/llm-response' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = () => { + return createLLMDocumentResponse({ + contentType: 'text/plain; charset=utf-8', + variant: 'strict' + }) +} \ No newline at end of file diff --git a/apps/documentation/src/routes/build.json/+server.ts b/apps/documentation/src/routes/build.json/+server.ts new file mode 100644 index 0000000..3e07af9 --- /dev/null +++ b/apps/documentation/src/routes/build.json/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = ({ platform }) => { + return json({ + buildSha: platform?.env.BUILD_SHA ?? 'unknown', + buildTime: platform?.env.BUILD_TIME ?? 'unknown' + }, { + headers: { + 'cache-control': 'no-store' + } + }) +} \ No newline at end of file diff --git a/apps/documentation/src/routes/docs/+layout.svelte b/apps/documentation/src/routes/docs/+layout.svelte new file mode 100644 index 0000000..cdd1123 --- /dev/null +++ b/apps/documentation/src/routes/docs/+layout.svelte @@ -0,0 +1,7 @@ + + +
+ {@render children()} +
diff --git a/apps/documentation/src/routes/docs/+page.ts b/apps/documentation/src/routes/docs/+page.ts new file mode 100644 index 0000000..0d744e3 --- /dev/null +++ b/apps/documentation/src/routes/docs/+page.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit' +import { docPath } from '$lib/docs/content' +import { extractLocaleFromUrl, localizeHref } from '$lib/paraglide/runtime' +import type { PageLoad } from './$types' + +export const load: PageLoad = ({ url }) => { + const locale = extractLocaleFromUrl(url) + throw redirect(308, localizeHref(docPath('what-devflare-is'), { locale })) +} \ No newline at end of file diff --git a/apps/documentation/src/routes/docs/[...slug]/+page.svelte b/apps/documentation/src/routes/docs/[...slug]/+page.svelte new file mode 100644 index 0000000..1732412 --- /dev/null +++ b/apps/documentation/src/routes/docs/[...slug]/+page.svelte @@ -0,0 +1,16 @@ + + +
\ No newline at end of file diff --git a/apps/documentation/src/routes/docs/[...slug]/+page.ts b/apps/documentation/src/routes/docs/[...slug]/+page.ts new file mode 100644 index 0000000..a6f5602 --- /dev/null +++ b/apps/documentation/src/routes/docs/[...slug]/+page.ts @@ -0,0 +1,24 @@ +import { error, redirect } from '@sveltejs/kit' +import { docPath, getAdjacentDocs, getCanonicalDocSlug, getDoc } from '$lib/docs/content' +import { extractLocaleFromUrl, localizeHref } from '$lib/paraglide/runtime' + +export function load({ params, url }) { + const slug = params.slug + const doc = getDoc(slug) + + if (!doc) { + throw error(404, `Unknown documentation page: ${slug}`) + } + + const canonicalSlug = getCanonicalDocSlug(slug) + + if (canonicalSlug && canonicalSlug !== slug) { + const locale = extractLocaleFromUrl(url) + throw redirect(308, localizeHref(docPath(canonicalSlug), { locale })) + } + + return { + doc, + ...getAdjacentDocs(slug) + } +} \ No newline at end of file diff --git a/apps/documentation/src/routes/layout.css b/apps/documentation/src/routes/layout.css new file mode 100644 index 0000000..96180dd --- /dev/null +++ b/apps/documentation/src/routes/layout.css @@ -0,0 +1,1669 @@ +@import "tailwindcss"; +@plugin '@tailwindcss/typography'; +@plugin '@iconify/tailwind4' { + prefixes: fluent, logos, material-icon-theme, twemoji; +} + +:root { + color-scheme: light; + --docs-measure: 68ch; + --docs-measure-tight: 60ch; + --docs-measure-wide: 76ch; + --docs-accent: #f48120; + --docs-accent-hover: #dd6d10; + --docs-accent-contrast: #23160a; + --docs-accent-soft: rgba(244, 129, 32, 0.12); + --docs-accent-soft-strong: rgba(244, 129, 32, 0.22); + --docs-accent-ring: rgba(244, 129, 32, 0.38); + --docs-link-underline: rgba(244, 129, 32, 0.34); + --docs-selection: rgba(244, 129, 32, 0.2); + --docs-bg-app: #f5f3ef; + --docs-bg-sidebar: #fbfaf8; + --docs-bg-header: rgba(251, 250, 248, 0.88); + --docs-bg-surface: #ffffff; + --docs-bg-surface-soft: #f7f4ef; + --docs-bg-surface-nav: #f3eee8; + --docs-bg-surface-hover: #efe8df; + --docs-bg-code: #f3efe9; + --docs-border: rgba(60, 44, 31, 0.12); + --docs-border-strong: rgba(60, 44, 31, 0.18); + --docs-text-strong: #17120d; + --docs-text-base: #3d3128; + --docs-text-muted: #695a4f; + --docs-text-subtle: #95877c; + --docs-shadow: 0 1px 2px rgba(23, 18, 13, 0.05), 0 16px 32px rgba(23, 18, 13, 0.04); + --docs-shadow-soft: 0 1px 1px rgba(23, 18, 13, 0.04); + --docs-code-surface: #f0ece5; + --docs-code-surface-soft: #f7f4ef; + --docs-code-surface-strong: #e6dfd6; + --docs-code-border: rgba(60, 44, 31, 0.14); + --docs-code-border-strong: rgba(60, 44, 31, 0.22); + --docs-code-text: #1d1814; + --docs-code-text-muted: #5d5148; + --docs-code-text-subtle: #877a70; + --docs-code-accent-soft: rgba(23, 18, 13, 0.055); + --docs-code-accent-strong: rgba(23, 18, 13, 0.1); + --docs-code-token-plain: #2c241d; + --docs-code-token-muted: #7f736a; + --docs-code-token-strong: #17120d; + --docs-code-token-soft: #595048; + --docs-code-syntax-plain: #0f766e; + --docs-code-syntax-muted: #6b7280; + --docs-code-syntax-strong: #005cc5; + --docs-code-syntax-soft: #9a6700; + --docs-theme-switch-sun: #a46a00; + --docs-theme-switch-moon: #8fc8ff; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --docs-accent-soft: rgba(244, 129, 32, 0.16); + --docs-accent-soft-strong: rgba(244, 129, 32, 0.28); + --docs-accent-ring: rgba(244, 129, 32, 0.44); + --docs-link-underline: rgba(244, 129, 32, 0.42); + --docs-selection: rgba(244, 129, 32, 0.26); + --docs-bg-app: #161616; + --docs-bg-sidebar: #101010; + --docs-bg-header: rgba(16, 16, 16, 0.88); + --docs-bg-surface: #1b1b1b; + --docs-bg-surface-soft: #202020; + --docs-bg-surface-nav: #181818; + --docs-bg-surface-hover: #262626; + --docs-bg-code: #121212; + --docs-border: rgba(255, 255, 255, 0.08); + --docs-border-strong: rgba(255, 255, 255, 0.12); + --docs-text-strong: #f5f5f5; + --docs-text-base: #d4d4d4; + --docs-text-muted: #acacac; + --docs-text-subtle: #7a7a7a; + --docs-shadow: 0 1px 2px rgba(0, 0, 0, 0.32), 0 20px 40px rgba(0, 0, 0, 0.22); + --docs-shadow-soft: 0 1px 1px rgba(0, 0, 0, 0.2); + --docs-code-surface: #171717; + --docs-code-surface-soft: #1d1d1d; + --docs-code-surface-strong: #232323; + --docs-code-border: rgba(255, 255, 255, 0.08); + --docs-code-border-strong: rgba(255, 255, 255, 0.14); + --docs-code-text: #ededed; + --docs-code-text-muted: #b6b6b6; + --docs-code-text-subtle: #7f7f7f; + --docs-code-accent-soft: rgba(255, 255, 255, 0.035); + --docs-code-accent-strong: rgba(255, 255, 255, 0.07); + --docs-code-token-plain: #ededed; + --docs-code-token-muted: #8c8c8c; + --docs-code-token-strong: #ffffff; + --docs-code-token-soft: #c7c7c7; + --docs-code-syntax-plain: #7ee787; + --docs-code-syntax-muted: #8b949e; + --docs-code-syntax-strong: #79c0ff; + --docs-code-syntax-soft: #f2cc8f; + --docs-theme-switch-sun: #ffd257; + --docs-theme-switch-moon: #8fc8ff; +} + +html { + scroll-behavior: auto; + background: var(--docs-bg-app); +} + +body { + min-height: 100vh; + background: var(--docs-bg-app); + color: var(--docs-text-base); + font-kerning: normal; + font-feature-settings: "ss01" 1, "ss03" 1, "cv11" 1; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +::selection { + background: var(--docs-selection); + color: var(--docs-text-strong); +} + +a { + text-decoration-color: var(--docs-link-underline); + text-underline-offset: 0.18em; +} + +code, +pre { + font-family: "Geist Mono", "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", + Menlo, Consolas, monospace; +} + +.docs-inline-code { + font-family: "Geist Mono", "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", + Menlo, Consolas, monospace; + font-size: 0.92em; + font-variant-ligatures: none; + background: var(--docs-bg-code); + border: 1px solid var(--docs-border); + border-radius: 0.45rem; + padding: 0.08em 0.35em; + color: var(--docs-text-strong); + white-space: break-spaces; +} + +.docs-inline-code-button { + appearance: none; + cursor: pointer; + display: inline-flex; + line-height: inherit; + text-align: inherit; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow + 0.18s ease; + vertical-align: baseline; +} + +.docs-inline-code-button:hover { + background: color-mix(in srgb, var(--docs-bg-code) 97%, var(--docs-text-strong) 3%); + border-color: color-mix(in srgb, var(--docs-border) 82%, var(--docs-text-strong) 18%); +} + +.docs-inline-code-button.docs-inline-code-copied { + background: color-mix(in srgb, var(--docs-bg-code) 92%, var(--docs-accent-soft) 8%); + border-color: var(--docs-border-strong); + color: var(--docs-text-strong); +} + +.docs-inline-code-content { + font: inherit; + white-space: inherit; +} + +.docs-shell { + background: var(--docs-bg-app); + color: var(--docs-text-base); +} + +.docs-sidebar-panel { + background: var(--docs-bg-sidebar); + border-color: var(--docs-border); + box-shadow: inset -1px 0 0 var(--docs-border); +} + +.docs-mobile-header { + background: var(--docs-bg-header); + border-color: var(--docs-border); +} + +.docs-brand-mark { + background: var(--docs-accent-soft); + color: var(--docs-accent); + box-shadow: inset 0 0 0 1px var(--docs-accent-soft-strong); +} + +.docs-surface-panel { + background: var(--docs-bg-surface); + border: 1px solid var(--docs-border); + box-shadow: var(--docs-shadow-soft); +} + +.docs-reading-viewport-action { + background: color-mix(in srgb, var(--docs-bg-surface) 46%, transparent); + border: 1px solid color-mix(in srgb, var(--docs-border) 46%, transparent); + border-radius: 0.38rem; + box-shadow: none; + color: var(--docs-text-strong); + text-decoration: none; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s + ease; +} + +.docs-reading-viewport-action:hover { + background: color-mix(in srgb, var(--docs-accent-soft) 74%, var(--docs-bg-surface) 26%); + border-color: var(--docs-accent-soft-strong); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--docs-accent-soft-strong) 76%, transparent); + color: var(--docs-accent); +} + +.docs-reading-viewport-actions-desktop { + inset-inline-start: calc(16rem + clamp(0.35rem, 1vw, 0.85rem)); + position: fixed; + top: clamp(0.35rem, 1vw, 0.85rem); + z-index: 20; +} + +.docs-reading-viewport-actions-mobile { + grid-template-columns: minmax(0, 1fr); +} + +.docs-reading-viewport-action-mobile { + background: var(--docs-bg-surface-nav); + border-color: var(--docs-border); + border-radius: 0.8rem; + justify-content: flex-start; + min-height: 2.75rem; + width: 100%; +} + +.docs-reading-viewport-action-icon { + display: inline-block; + flex: 0 0 auto; + transition: color 0.2s ease, opacity 0.2s ease; +} + +.docs-reading-viewport-action-icon-github { + color: currentColor; + display: block; +} + +@media (max-width: 1023px) { + .docs-reading-viewport-actions-desktop { + display: none; + } +} + +.docs-tooltip-shell { + pointer-events: none; + z-index: 80; +} + +.docs-tooltip { + background: color-mix(in srgb, var(--docs-bg-surface) 90%, transparent); + -webkit-backdrop-filter: blur(16px) saturate(1.08); + backdrop-filter: blur(16px) saturate(1.08); + border: 1px solid color-mix(in srgb, var(--docs-border) 84%, transparent); + border-radius: 0.5rem; + box-shadow: 0 10px 30px rgba(18, 16, 14, 0.12), 0 2px 8px rgba(18, 16, 14, 0.06); + color: var(--docs-text-strong); + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.2; + max-width: min(24rem, calc(100vw - 1rem)); + padding: 0.42rem 0.58rem; + text-wrap: pretty; +} + +.docs-intellisense-shell { + pointer-events: auto; + z-index: 85; +} + +.docs-intellisense-panel { + background: color-mix(in srgb, var(--docs-bg-surface) 90%, transparent); + -webkit-backdrop-filter: blur(18px) saturate(1.08); + backdrop-filter: blur(18px) saturate(1.08); + border: 1px solid color-mix(in srgb, var(--docs-border-strong) 88%, transparent); + border-radius: 0.82rem; + box-shadow: 0 18px 46px rgba(18, 16, 14, 0.14), 0 2px 10px rgba(18, 16, 14, 0.08); + color: var(--docs-text-strong); + max-width: min(24.5rem, calc(100vw - 1rem)); + overflow: hidden; + padding: 0.88rem; + position: relative; + width: min(24.5rem, calc(100vw - 1rem)); +} + +.docs-intellisense-content { + display: grid; + gap: 0.62rem; + position: relative; + z-index: 1; +} + +.docs-intellisense-cloudflare-mark { + inset-block-start: -0.35rem; + inset-inline-end: -0.1rem; + opacity: 0.18; + pointer-events: none; + position: absolute; + transform: rotate(-8deg); + z-index: 0; +} + +.docs-intellisense-head { + display: grid; + gap: 0.18rem; +} + +.docs-intellisense-kicker { + color: var(--docs-text-subtle); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.14em; + margin: 0; + text-transform: uppercase; +} + +.docs-intellisense-title { + color: var(--docs-accent); + font-size: 0.96rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.2; + margin: 0; +} + +.docs-intellisense-copy { + display: grid; + gap: 0.34rem; + margin: 0; +} + +.docs-intellisense-summary { + color: var(--docs-text-strong); + font-size: 0.81rem; + font-weight: 650; + line-height: 1.4; + margin: 0; + text-wrap: pretty; +} + +.docs-intellisense-detail { + color: var(--docs-text-muted); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.55; + margin: 0; + text-wrap: pretty; +} + +.docs-intellisense-facts { + display: flex; + flex-wrap: wrap; + gap: 0.38rem; +} + +.docs-intellisense-fact { + align-items: center; + background: color-mix(in srgb, var(--docs-bg-surface-soft) 78%, transparent); + border: 1px solid color-mix(in srgb, var(--docs-border) 78%, transparent); + border-radius: 999px; + color: var(--docs-text-muted); + display: inline-flex; + font-size: 0.67rem; + font-weight: 600; + gap: 0.35rem; + line-height: 1; + max-width: 100%; + padding: 0.3rem 0.48rem; +} + +.docs-intellisense-fact-emphasis { + background: color-mix(in srgb, var(--docs-accent-soft) 72%, transparent); + border-color: color-mix(in srgb, var(--docs-accent-soft-strong) 76%, transparent); + color: var(--docs-accent); +} + +.docs-intellisense-fact-label { + color: var(--docs-text-subtle); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs-intellisense-links { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.docs-intellisense-link { + align-items: center; + background: color-mix(in srgb, var(--docs-bg-surface-soft) 74%, transparent); + border: 1px solid color-mix(in srgb, var(--docs-border) 82%, transparent); + border-radius: 999px; + color: var(--docs-text-strong); + display: inline-flex; + font-size: 0.68rem; + font-weight: 650; + gap: 0.32rem; + line-height: 1; + max-width: 100%; + min-height: 1.8rem; + min-width: 0; + overflow: hidden; + padding: 0.34rem 0.58rem; + text-decoration: none; +} + +.docs-intellisense-link:hover { + background: color-mix(in srgb, var(--docs-accent-soft) 68%, transparent); + border-color: color-mix(in srgb, var(--docs-accent-soft-strong) 74%, transparent); + color: var(--docs-text-strong); +} + +.docs-intellisense-link-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docs-intellisense-link-cloudflare { + background: color-mix(in srgb, var(--docs-accent-soft) 46%, transparent); + border-color: color-mix(in srgb, var(--docs-accent-soft-strong) 68%, transparent); + justify-content: center; + min-width: 1.8rem; + padding-inline: 0.42rem; +} + +.docs-intellisense-link-cloudflare:hover { + background: color-mix(in srgb, var(--docs-accent-soft) 72%, transparent); + border-color: color-mix(in srgb, var(--docs-accent-soft-strong) 82%, transparent); +} + +.docs-intellisense-link-icon { + display: block; +} + +.docs-surface-glass { + background: var(--docs-bg-surface-soft); + border: 1px solid var(--docs-border); + box-shadow: var(--docs-shadow-soft); +} + +.docs-surface-nav { + background: var(--docs-bg-surface-nav); + border: 1px solid var(--docs-border); + box-shadow: var(--docs-shadow-soft); +} + +.docs-surface-code { + background: var(--docs-bg-code); + border: 1px solid var(--docs-border); + box-shadow: var(--docs-shadow-soft); +} + +.docs-surface-transition { + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s + ease; +} + +.docs-border { + border-color: var(--docs-border); +} + +.docs-border-strong { + border-color: var(--docs-border-strong); +} + +.docs-text-strong { + color: var(--docs-text-strong); +} + +.docs-text-body { + color: var(--docs-text-base); +} + +.docs-text-muted { + color: var(--docs-text-muted); +} + +.docs-text-subtle { + color: var(--docs-text-subtle); +} + +.docs-text-accent { + color: var(--docs-accent); +} + +.docs-accent-dot { + background: var(--docs-accent); +} + +.docs-active-card { + background: var(--docs-accent-soft); + box-shadow: inset 0 0 0 1px var(--docs-accent-soft-strong); +} + +.docs-nav-link { + color: var(--docs-text-muted); +} + +.docs-nav-link:hover { + background: var(--docs-bg-surface-soft); + color: var(--docs-text-strong); +} + +.docs-nav-link-active { + background: var(--docs-accent-soft); + color: var(--docs-text-strong); + box-shadow: inset 0 0 0 1px var(--docs-accent-soft-strong); +} + +.docs-toc-link { + align-items: baseline; + border-left: 2px solid transparent; + column-gap: 0.95rem; + color: var(--docs-text-muted); + display: grid; + grid-template-columns: 1.7rem minmax(0, 1fr); +} + +.docs-toc-link:hover { + background: var(--docs-bg-surface-soft); + color: var(--docs-text-strong); +} + +.docs-toc-link-active { + background: var(--docs-accent-soft); + border-left-color: var(--docs-accent); + box-shadow: inset 0 0 0 1px var(--docs-accent-soft-strong); + color: var(--docs-text-strong); +} + +.docs-toc-index { + color: var(--docs-text-subtle); + flex: 0 0 auto; + min-width: 1.7rem; +} + +.docs-toc-title { + color: inherit; + display: block; + min-width: 0; + text-wrap: pretty; +} + +.docs-toc-link:hover .docs-toc-index, +.docs-toc-link-active .docs-toc-index { + color: var(--docs-accent); +} + +.docs-article-content { + transform: translateX(var(--docs-article-content-offset, 0px)); + transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; +} + +.docs-floating-toc { + position: fixed; + inset-block-start: clamp(6.75rem, 11vh, 8.5rem); + inset-inline-end: clamp(0.5rem, 1vw, 1rem); + width: var(--docs-toc-expanded-width); + max-width: min(calc(100vw - 0.75rem), var(--docs-toc-expanded-width)); + max-block-size: calc(100svh - 7.5rem); + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; + transition: + width 0.26s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; + z-index: 30; +} + +.docs-floating-toc[data-ready="false"] { + pointer-events: none; + visibility: hidden; +} + +.docs-floating-toc::-webkit-scrollbar { + display: none; +} + +.docs-floating-toc-full, +.docs-floating-toc-narrow { + border: 0; + padding-block: 0.25rem; + padding-inline: 0; + background: transparent; + box-shadow: none; +} + +.docs-floating-toc-numbers { + width: var(--docs-toc-collapsed-width); + border: 1px solid transparent; + border-radius: 0; + padding-block: 0.25rem; + padding-inline: 0; + background: transparent; + box-shadow: none; + contain: layout style; + will-change: width; +} + +.docs-floating-toc-numbers:hover, +.docs-floating-toc-numbers:focus-within, +.docs-floating-toc-numbers.docs-floating-toc-pinned { + width: var(--docs-toc-expanded-width); + border-color: var(--docs-border); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--docs-bg-app) 94%, transparent); + box-shadow: var(--docs-shadow-soft); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + z-index: 40; +} + +.docs-floating-toc-list { + display: grid; + gap: 0.25rem; +} + +.docs-floating-toc-link { + align-items: baseline; + border-left: 2px solid transparent; + color: var(--docs-text-muted); + column-gap: 0.95rem; + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr); + overflow: hidden; + padding-block: 0.45rem; + padding-inline-end: 0.55rem; + padding-inline-start: 0.8rem; + text-decoration: none; + transition: border-color 0.18s ease, color 0.18s ease; +} + +.docs-floating-toc-link:hover { + color: var(--docs-text-strong); + background: transparent; +} + +.docs-floating-toc-link-active { + border-left-color: var(--docs-accent); + color: var(--docs-text-strong); + background: transparent; + box-shadow: none; +} + +.docs-floating-toc-index { + color: var(--docs-text-subtle); + flex: 0 0 auto; + inline-size: 1.75rem; + line-height: 1.5rem; + min-inline-size: 1.75rem; + text-align: left; + transition: color 0.18s ease; +} + +.docs-floating-toc-title-shell { + display: block; + inline-size: min(100%, var(--docs-toc-title-width)); + min-inline-size: 0; + overflow: hidden; +} + +.docs-floating-toc-title { + color: inherit; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docs-floating-toc-full .docs-floating-toc-title, +.docs-floating-toc-narrow .docs-floating-toc-title, +.docs-floating-toc-numbers:hover .docs-floating-toc-title, +.docs-floating-toc-numbers:focus-within .docs-floating-toc-title, +.docs-floating-toc-numbers.docs-floating-toc-pinned .docs-floating-toc-title { + text-overflow: clip; + text-wrap: pretty; + white-space: normal; +} + +.docs-floating-toc-link:hover .docs-floating-toc-index, +.docs-floating-toc-link-active .docs-floating-toc-index { + color: var(--docs-accent); +} + +.docs-floating-toc-numbers .docs-floating-toc-link { + padding-inline-end: 0.65rem; + padding-inline-start: 0.8rem; +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-list { + gap: 0.08rem; +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-link { + align-items: center; + border-left-color: transparent; + display: flex; + justify-content: center; + min-block-size: 1.7rem; + padding: 0.18rem 0.35rem; +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-title-shell { + display: none; +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-index { + inline-size: auto; + line-height: 1.2rem; + min-inline-size: 0; + text-align: center; +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-link-active { + color: var(--docs-text-muted); +} + +.docs-floating-toc-numbers:not(:hover):not(:focus-within):not(.docs-floating-toc-pinned) + .docs-floating-toc-link-active + .docs-floating-toc-index { + color: var(--docs-accent); +} + +@media (prefers-reduced-motion: reduce) { + .docs-floating-toc { + transition: none; + } + + .docs-article-content { + transition: none; + } +} + +.docs-hover-soft:hover { + background: var(--docs-bg-surface-soft); +} + +.docs-hover-strong:hover { + background: var(--docs-bg-surface-hover); +} + +.docs-hover-text-strong:hover { + color: var(--docs-text-strong); +} + +.docs-callout-header { + align-items: start; + display: flex; + gap: 0.625rem; + --docs-first-line-height: 1.2rem; +} + +.docs-callout-icon { + margin-top: calc((var(--docs-first-line-height) - 1.25rem) / 2); +} + +.docs-callout-cta { + border-top: 1px solid color-mix(in srgb, var(--docs-border) 82%, transparent); + padding-top: 1rem; +} + +.docs-callout-cta-kicker { + color: var(--docs-accent); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.docs-callout-cta-copy { + color: var(--docs-text-strong); + font-size: 0.98rem; + font-weight: 500; + line-height: 1.45; +} + +.docs-bullet-item { + align-items: start; + column-gap: 0.75rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + --docs-first-line-height: 1.75rem; +} + +.docs-bullet-marker { + margin-top: calc((var(--docs-first-line-height) - 0.5rem) / 2); +} + +.docs-step-item { + --docs-first-line-height: 1.75rem; +} + +.docs-step-index { + background: var(--docs-accent-soft); + color: var(--docs-accent); + margin-top: calc((var(--docs-first-line-height) - 2rem) / 2); +} + +.docs-text-accent-hover { + color: var(--docs-accent-hover); +} + +.docs-primary-button { + background: var(--docs-accent); + color: var(--docs-accent-contrast); + border: 1px solid transparent; +} + +.docs-primary-button:hover { + background: var(--docs-accent-hover); +} + +.docs-secondary-button { + background: var(--docs-bg-surface-soft); + border: 1px solid var(--docs-border); + color: var(--docs-text-strong); +} + +.docs-secondary-button:hover { + background: var(--docs-bg-surface-hover); +} + +.docs-chip-button { + background: transparent; + border: 1px solid transparent; + color: var(--docs-text-base); +} + +.docs-chip-button:hover { + background: var(--docs-bg-surface-soft); + border-color: var(--docs-border); + color: var(--docs-text-strong); +} + +.docs-theme-switch { + --docs-theme-switch-width: 3.75rem; + --docs-theme-switch-height: 2rem; + --docs-theme-switch-padding: 0.2rem; + --docs-theme-switch-thumb-size: calc( + var(--docs-theme-switch-height) - + (var(--docs-theme-switch-padding) * 2) + ); + background: transparent; + border: 0; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + padding: 0; +} + +.docs-theme-switch-track { + align-items: center; + background: var(--docs-bg-surface-soft); + border: 1px solid var(--docs-border); + border-radius: 999px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + height: var(--docs-theme-switch-height); + padding-inline: 0.42rem; + position: relative; + width: var(--docs-theme-switch-width); + transition: background-color 0.18s ease, border-color 0.18s ease; +} + +.docs-theme-switch:hover .docs-theme-switch-track { + background: var(--docs-bg-surface-hover); + border-color: var(--docs-border-strong); +} + +.docs-theme-switch-icon { + align-items: center; + color: color-mix(in srgb, var(--docs-text-muted) 80%, var(--docs-text-strong) 20%); + display: inline-flex; + height: 1rem; + justify-content: center; + opacity: 0.88; + position: relative; + z-index: 0; + transition: color 0.18s ease, opacity 0.18s ease; +} + +.docs-theme-switch-icon svg { + display: block; + height: 0.88rem; + width: 0.88rem; +} + +.docs-theme-switch-thumb { + align-items: center; + background: var(--docs-bg-surface); + border: 1px solid var(--docs-border-strong); + border-radius: 999px; + box-sizing: border-box; + box-shadow: var(--docs-shadow-soft); + display: inline-flex; + height: var(--docs-theme-switch-thumb-size); + inset-inline-start: var(--docs-theme-switch-padding); + justify-content: center; + position: absolute; + top: var(--docs-theme-switch-padding); + transform: translateX(0); + transition: transform 0.2s ease, background-color 0.18s ease, border-color 0.18s ease; + width: var(--docs-theme-switch-thumb-size); + z-index: 1; +} + +.docs-theme-switch-thumb[data-theme="dark"] { + transform: translateX( + calc( + var(--docs-theme-switch-width) - + var(--docs-theme-switch-thumb-size) - + (var(--docs-theme-switch-padding) * 2) + ) + ); +} + +.docs-theme-switch-thumb-icon { + align-items: center; + display: inline-flex; + height: 100%; + justify-content: center; + line-height: 1; + transition: color 0.18s ease; + width: 100%; +} + +.docs-theme-switch-thumb-icon[data-theme="light"] { + color: var(--docs-theme-switch-sun); +} + +.docs-theme-switch-thumb-icon[data-theme="dark"] { + color: var(--docs-theme-switch-moon); +} + +.docs-theme-switch-thumb-icon svg { + display: block; + height: 0.78rem; + width: 0.78rem; +} + +@media (prefers-reduced-motion: reduce) { + .docs-theme-switch-track, + .docs-theme-switch-icon, + .docs-theme-switch-thumb, + .docs-theme-switch-thumb-icon { + transition: none; + } +} + +.docs-code-button { + background: var(--docs-bg-surface-soft); + border: 1px solid var(--docs-border); + color: var(--docs-text-base); +} + +.docs-code-button:hover { + background: var(--docs-bg-surface-hover); + color: var(--docs-text-strong); +} + +.docs-code-shell { + background: var(--docs-code-surface); + border: 1px solid var(--docs-code-border); + box-shadow: 0 1px 1px rgba(23, 18, 13, 0.08), 0 16px 34px rgba(23, 18, 13, 0.08); + color: var(--docs-code-text); +} + +.docs-code-header { + background: color-mix(in srgb, var(--docs-code-surface-soft) 86%, var(--docs-bg-surface) 14%); +} + +.docs-code-panel-border { + border-color: var(--docs-code-border); +} + +.docs-code-title { + color: var(--docs-code-token-strong); +} + +.docs-code-description { + color: var(--docs-code-text-muted); +} + +.docs-code-kind { + align-items: center; + color: var(--docs-code-text-subtle); + display: inline-flex; + justify-content: center; + line-height: 1; + min-height: 1.5rem; + padding-top: 0.1rem; +} + +.docs-code-meta { + color: var(--docs-code-text-muted); +} + +.docs-code-pill { + background: var(--docs-code-accent-soft); + border: 1px solid var(--docs-code-border); + color: var(--docs-code-token-soft); + max-width: min(40rem, 100%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docs-code-language { + background: transparent; + border: 1px solid var(--docs-code-border-strong); + color: var(--docs-code-token-soft); + text-transform: lowercase; +} + +.docs-code-toolbar-button { + background: transparent; + border: 1px solid var(--docs-code-border); + color: var(--docs-code-token-soft); +} + +.docs-code-toolbar-button:hover { + background: var(--docs-code-accent-soft); + border-color: var(--docs-code-border-strong); + color: var(--docs-code-token-strong); +} + +.docs-code-tab { + align-items: center; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + border-inline-end: 1px solid var(--docs-code-border); + border-radius: 0; + color: var(--docs-code-text-muted); + display: inline-flex; + font-size: 0.8rem; + font-weight: 550; + gap: 0.5rem; + max-width: 100%; + padding: 0.8rem 1rem 0.72rem; + position: relative; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.docs-code-tab:not(.docs-code-tab-active):hover { + background: var(--docs-code-accent-soft); + color: var(--docs-code-text); +} + +.docs-code-tab-active { + background: var(--docs-code-surface); + border-bottom-color: var(--docs-accent); + color: var(--docs-code-token-strong); + cursor: default; +} + +.docs-code-tab-icon { + opacity: 0.78; +} + +.docs-code-tabs { + align-items: stretch; + background: color-mix(in srgb, var(--docs-code-surface-soft) 82%, transparent); + border-bottom: 1px solid var(--docs-code-border); + display: flex; + gap: 0; + overflow-x: auto; + padding-top: 0; + scrollbar-width: none; +} + +.docs-code-tabs::-webkit-scrollbar { + display: none; +} + +.docs-code-tree { + background: color-mix(in srgb, var(--docs-code-surface-strong) 58%, transparent); + min-width: 0; +} + +.docs-code-tree-label { + color: var(--docs-code-text-subtle); +} + +.docs-code-tree-item { + align-items: center; + color: var(--docs-code-text-muted); + display: grid; + grid-template-columns: calc(var(--docs-code-depth) * 1.45rem) minmax(0, 1fr); + line-height: 1.25rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + width: 100%; +} + +.docs-code-tree-indent { + display: block; + min-width: 0; + width: 100%; +} + +.docs-code-tree-entry { + align-items: center; + display: flex; + gap: 0.45rem; + min-width: 0; + width: 100%; +} + +.docs-code-tree-folder { + color: var(--docs-code-token-soft); +} + +.docs-code-tree-folder-toggle { + background: transparent; + border: 0; + cursor: pointer; + text-align: left; +} + +.docs-code-tree-folder-toggle:hover { + background: var(--docs-code-accent-soft); + color: var(--docs-code-token-strong); +} + +.docs-code-tree-file { + background: transparent; + border: 0; +} + +.docs-code-tree-file-button { + cursor: pointer; +} + +.docs-code-tree-file-button:hover { + background: var(--docs-code-accent-soft); + color: var(--docs-code-text); +} + +.docs-code-tree-file-static { + cursor: default; +} + +.docs-code-tree-item-active { + background: var(--docs-code-accent-soft); + box-shadow: inset 0 0 0 1px var(--docs-code-border); + color: var(--docs-code-token-strong); +} + +.docs-code-tree-item-muted { + color: var(--docs-code-text-subtle); + opacity: 0.78; +} + +.docs-code-tree-icon { + color: var(--docs-code-text-subtle); + display: block; + height: 1rem; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + line-height: 1; + width: 1rem; +} + +.docs-code-tree-toggle-slot { + align-items: center; + display: grid; + flex: 0 0 1rem; + height: 1rem; + justify-items: center; + line-height: 1; + place-items: center; + width: 1rem; +} + +.docs-code-tree-chevron { + color: var(--docs-code-text-subtle); + display: block; + height: 1rem; + width: 1rem; +} + +.docs-code-scroll { + background: transparent; + flex: 1 1 auto; + max-height: 32rem; + min-height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding-block: 0; + scrollbar-color: color-mix(in srgb, var(--docs-code-token-muted) 48%, transparent) transparent; + scrollbar-width: thin; +} + +.docs-code-pane { + display: flex; + flex-direction: column; + min-height: 100%; + position: relative; +} + +.docs-code-copy-button { + background: color-mix(in srgb, var(--docs-code-surface) 88%, transparent); + border: 1px solid var(--docs-code-border); + border-radius: 0.5rem; + color: var(--docs-code-token-soft); + opacity: 0.14; + transition: opacity 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, color 0.18s + ease; +} + +.docs-code-pane:hover .docs-code-copy-button, +.docs-code-pane:focus-within .docs-code-copy-button { + opacity: 0.58; +} + +.docs-code-copy-button:hover, +.docs-code-copy-button:focus-visible { + background: var(--docs-code-accent-soft); + border-color: var(--docs-code-border-strong); + color: var(--docs-code-token-strong); + opacity: 1; +} + +.docs-code-intellisense-token { + -webkit-box-decoration-break: clone; + background: transparent; + border-radius: 0.28rem; + box-decoration-break: clone; + cursor: text; + text-decoration-color: color-mix(in srgb, var(--docs-code-text-subtle) 58%, transparent); + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 0.22em; + transition: background-color 0.18s ease, color 0.18s ease, text-decoration-color 0.18s ease; +} + +.docs-code-intellisense-token:hover { + background: color-mix(in srgb, var(--docs-code-accent-soft) 52%, transparent); + color: inherit; + text-decoration-color: color-mix(in srgb, var(--docs-accent) 82%, transparent); +} + +.docs-code-content, +.docs-code-line-content, +.docs-code-line-content * { + cursor: text; +} + +.docs-code-pre { + --docs-code-gutter-width: 3.75rem; + font-family: "Geist Mono", "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", + Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.45rem; + margin: 0; + min-height: 100%; + min-width: 0; + padding: 0; + position: relative; + width: 100%; +} + +.docs-code-pre::before { + border-left: 1px solid var(--docs-code-border); + content: ""; + inset-block: 0; + inset-inline-start: var(--docs-code-gutter-width); + pointer-events: none; + position: absolute; + transform: translateX(-0.5px); +} + +@media (min-width: 768px) { + .docs-code-pre { + font-size: 13.5px; + line-height: 1.5rem; + } +} + +.docs-code-content { + display: block; + font-variant-ligatures: none; + height: 100%; + min-height: 100%; + min-width: 0; + position: relative; + white-space: normal; + width: 100%; +} + +.docs-code-line-set { + min-height: 100%; + padding-block: 0.75rem; + position: relative; +} + +.docs-code-line { + align-items: start; + border-left: 2px solid transparent; + display: grid; + grid-template-columns: var(--docs-code-gutter-width) minmax(0, 1fr); + line-height: inherit; + min-width: 0; + position: relative; + transition: opacity 0.18s ease, background-color 0.18s ease, border-color 0.18s ease; + z-index: 1; +} + +.docs-code-gutter-button { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: block; + font: inherit; + line-height: inherit; + padding: 0; + text-align: inherit; + transition: background-color 0.18s ease, color 0.18s ease; + width: 100%; +} + +.docs-code-gutter-button:hover, +.docs-code-gutter-button:focus-visible { + background: color-mix(in srgb, var(--docs-code-accent-soft) 58%, transparent); + color: var(--docs-code-text); +} + +.docs-code-line-focus { + background: var(--docs-code-accent-soft); + border-left-color: var(--docs-accent); +} + +.docs-code-line-dim { + opacity: 0.5; +} + +.docs-code-gutter { + align-self: start; + color: inherit; + display: block; + padding: 0.1rem 0.9rem 0.1rem 1rem; + text-align: right; + user-select: none; + width: 100%; +} + +.docs-code-line-content { + color: var(--docs-code-text); + display: block; + min-width: 0; + overflow-wrap: anywhere; + padding: 0.1rem 1.1rem; + tab-size: 4; + white-space: pre-wrap; + word-break: normal; +} + +.docs-code-shell code[class*="language-"], +.docs-code-shell pre[class*="language-"] { + background: transparent; + color: var(--docs-code-text); + text-shadow: none; +} + +.docs-code-shell .token.comment, +.docs-code-shell .token.prolog, +.docs-code-shell .token.doctype, +.docs-code-shell .token.cdata { + color: var(--docs-code-syntax-muted); +} + +.docs-code-shell .token.punctuation { + color: var(--docs-code-syntax-soft); +} + +.docs-code-shell .token.namespace { + opacity: 0.8; +} + +.docs-code-shell .token.property, +.docs-code-shell .token.key, +.docs-code-shell .token.literal-property, +.docs-code-shell .token.tag, +.docs-code-shell .token.constant, +.docs-code-shell .token.symbol, +.docs-code-shell .token.anchor, +.docs-code-shell .token.alias, +.docs-code-shell .token.deleted { + color: var(--docs-code-syntax-strong); +} + +.docs-code-shell .token.boolean, +.docs-code-shell .token.number { + color: var(--docs-code-syntax-soft); +} + +.docs-code-shell .token.selector, +.docs-code-shell .token.attr-name, +.docs-code-shell .token.string, +.docs-code-shell .token.char, +.docs-code-shell .token.builtin, +.docs-code-shell .token.inserted { + color: var(--docs-code-syntax-plain); +} + +.docs-code-shell .token.operator, +.docs-code-shell .token.entity, +.docs-code-shell .token.url, +.docs-code-shell .language-css .token.string, +.docs-code-shell .style .token.string { + color: var(--docs-code-syntax-soft); +} + +.docs-code-shell .token.atrule, +.docs-code-shell .token.attr-value, +.docs-code-shell .token.keyword { + color: var(--docs-code-syntax-strong); +} + +.docs-code-shell .token.function, +.docs-code-shell .token.class-name, +.docs-code-shell .token.parameter { + color: var(--docs-code-syntax-soft); +} + +.docs-code-shell .token.regex, +.docs-code-shell .token.important, +.docs-code-shell .token.variable { + color: var(--docs-code-syntax-plain); +} + +.docs-focus-ring:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--docs-accent-ring); +} + +.docs-divide > * + * { + border-top: 1px solid var(--docs-border); +} + +.docs-table-shell { + max-inline-size: 100%; + overflow: hidden; +} + +.docs-table-scroll { + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-gutter: stable; +} + +.docs-table { + border-collapse: collapse; + inline-size: 100%; + min-inline-size: 100%; + table-layout: auto; +} + +.docs-table-heading, +.docs-table-cell { + max-width: none; + vertical-align: top; + word-break: normal; + overflow-wrap: normal; +} + +.docs-table .docs-inline-code { + white-space: nowrap; +} + +.docs-table-wide { + min-inline-size: 72rem; +} + +.docs-table-wide .docs-table-heading:first-child, +.docs-table-wide .docs-table-cell:first-child, +.docs-table-wide .docs-table-heading:nth-child(2), +.docs-table-wide .docs-table-cell:nth-child(2), +.docs-table-wide .docs-table-heading:last-child, +.docs-table-wide .docs-table-cell:last-child { + white-space: nowrap; +} + +.docs-table-wide .docs-table-heading:first-child, +.docs-table-wide .docs-table-cell:first-child { + min-inline-size: 9.5rem; +} + +.docs-table-wide .docs-table-heading:nth-child(2), +.docs-table-wide .docs-table-cell:nth-child(2) { + min-inline-size: 5.5rem; +} + +.docs-table-wide .docs-table-heading:last-child, +.docs-table-wide .docs-table-cell:last-child { + min-inline-size: 6.5rem; +} + +@media (min-width: 1280px) { + .docs-table-shell-wide { + /* Wide tables grow left so the floating table of contents stays clear on the right. */ + inline-size: calc(100% + clamp(4rem, calc((100vw - 64rem) / 4), 16rem)); + margin-inline-start: calc(-1 * clamp(4rem, calc((100vw - 64rem) / 4), 16rem)); + max-inline-size: calc(100vw - 18rem); + } +} + +.docs-backdrop-blur { + backdrop-filter: blur(16px); +} + +.docs-table-head { + background: var(--docs-bg-surface-soft); +} + +.docs-prose a { + color: var(--docs-accent); +} + +.docs-prose strong { + color: var(--docs-text-strong); +} + +.docs-measure { + max-width: var(--docs-measure); +} + +.docs-measure-tight { + max-width: var(--docs-measure-tight); +} + +.docs-measure-wide { + max-width: var(--docs-measure-wide); +} + +.docs-display, +.docs-title-xl, +.docs-title-lg, +.docs-title-md, +.docs-title-sm { + text-wrap: balance; +} + +.docs-display { + font-size: clamp(2.2rem, 1.7rem + 2.5vw, 3.6rem); + line-height: 1.0; + font-weight: 650; + letter-spacing: -0.04em; + max-width: 18ch; +} + +.docs-title-xl { + font-size: clamp(2rem, 1.75rem + 1vw, 2.625rem); + line-height: 1.02; + font-weight: 650; + letter-spacing: -0.036em; + max-width: 16ch; +} + +.docs-article-title { + max-inline-size: clamp(75%, 56rem, 100%); +} + +.docs-title-lg { + font-size: clamp(1.75rem, 1.45rem + 1vw, 2.45rem); + line-height: 1.06; + font-weight: 650; + letter-spacing: -0.032em; +} + +.docs-title-md { + font-size: clamp(1.2rem, 1.08rem + 0.6vw, 1.62rem); + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.022em; +} + +.docs-title-sm { + font-size: 1.02rem; + line-height: 1.35; + font-weight: 650; + letter-spacing: -0.014em; +} + +.docs-kicker { + font-size: 0.73rem; + line-height: 1.2rem; + font-weight: 600; + letter-spacing: 0.08em; + text-wrap: balance; +} + +.docs-label { + font-size: 0.78rem; + line-height: 1.2rem; + font-weight: 600; + letter-spacing: 0.04em; + text-wrap: balance; +} + +.docs-meta { + font-size: 0.93rem; + line-height: 1.55; + text-wrap: pretty; +} + +.docs-copy-lg, +.docs-copy, +.docs-copy-sm, +.docs-list { + text-wrap: pretty; +} + +.docs-copy-lg { + max-width: 62ch; + font-size: clamp(1.05rem, 1rem + 0.38vw, 1.2rem); + line-height: 1.68; +} + +.docs-copy { + max-width: var(--docs-measure); + font-size: clamp(0.99rem, 0.97rem + 0.18vw, 1.06rem); + line-height: 1.78; +} + +.docs-copy-sm { + max-width: var(--docs-measure-wide); + font-size: 0.95rem; + line-height: 1.7; +} + +.docs-list { + max-width: var(--docs-measure); + font-size: 0.99rem; + line-height: 1.75; +} + +.docs-prose > * + * { + margin-top: 1.25rem; +} diff --git a/apps/documentation/static/devflare-logo.svg b/apps/documentation/static/devflare-logo.svg new file mode 100644 index 0000000..536d841 --- /dev/null +++ b/apps/documentation/static/devflare-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/documentation/static/robots.txt b/apps/documentation/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/apps/documentation/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/apps/documentation/svelte.config.js b/apps/documentation/svelte.config.js new file mode 100644 index 0000000..59d4f9a --- /dev/null +++ b/apps/documentation/svelte.config.js @@ -0,0 +1,20 @@ +import adapter from '@sveltejs/adapter-cloudflare' + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + kit: { + adapter: adapter({ + config: '.devflare/wrangler.jsonc', + platformProxy: { + configPath: '.devflare/wrangler.jsonc', + persist: true + } + }) + } +} + +export default config diff --git a/apps/documentation/tmp/codeblock-fetch-tab.png b/apps/documentation/tmp/codeblock-fetch-tab.png new file mode 100644 index 0000000..936ef36 Binary files /dev/null and b/apps/documentation/tmp/codeblock-fetch-tab.png differ diff --git a/apps/documentation/tmp/codeblock-tree-collapsed.png b/apps/documentation/tmp/codeblock-tree-collapsed.png new file mode 100644 index 0000000..c9c8605 Binary files /dev/null and b/apps/documentation/tmp/codeblock-tree-collapsed.png differ diff --git a/apps/documentation/tsconfig.json b/apps/documentation/tsconfig.json new file mode 100644 index 0000000..973504b --- /dev/null +++ b/apps/documentation/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "types": [ + "@cloudflare/workers-types", + "@sveltejs/kit" + ] + }, + "include": [ + "env.d.ts", + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.svelte", + "devflare.config.ts", + "vite.config.ts", + ".svelte-kit/types/**/*.d.ts" + ] +} diff --git a/apps/documentation/vite.config.ts b/apps/documentation/vite.config.ts new file mode 100644 index 0000000..5893e96 --- /dev/null +++ b/apps/documentation/vite.config.ts @@ -0,0 +1,70 @@ +import { paraglideVitePlugin } from '@inlang/paraglide-js' +import tailwindcss from '@tailwindcss/vite' +import { sveltekit } from '@sveltejs/kit/vite' +import { documentationUrlPatterns } from './paraglide-routing' +import { generateLLMDocuments, shouldRegenerateLLMDocuments } from './scripts/llm-documents' +import { devflarePlugin } from '../../packages/devflare/src/vite/index' +import { defineConfig, type Plugin } from 'vite' + +function llmDocumentsVitePlugin(): Plugin { + let activeGeneration: Promise | null = null + let pendingGeneration = false + + const regenerate = async (reason: string): Promise => { + if (activeGeneration) { + pendingGeneration = true + await activeGeneration + return + } + + activeGeneration = (async () => { + const result = await generateLLMDocuments() + console.log(`[documentation:llm] generated ${result.outputFiles.join(', ')} (${reason})`) + })() + + try { + await activeGeneration + } finally { + activeGeneration = null + } + + if (!pendingGeneration) { + return + } + + pendingGeneration = false + await regenerate('pending source change') + } + + return { + name: 'documentation-llm-documents', + async buildStart() { + await regenerate('build start') + }, + configureServer() { + void regenerate('dev server start') + }, + async handleHotUpdate(context) { + if (!shouldRegenerateLLMDocuments(context.file)) { + return + } + + await regenerate(`hot update: ${context.file.replace(/\\/g, '/')}`) + } + } +} + +export default defineConfig({ + plugins: [ + llmDocumentsVitePlugin(), + devflarePlugin(), + tailwindcss(), + sveltekit(), + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/lib/paraglide', + strategy: ['url', 'baseLocale'], + urlPatterns: documentationUrlPatterns + }) + ] +}) diff --git a/apps/documentation/worker-name.ts b/apps/documentation/worker-name.ts new file mode 100644 index 0000000..0438499 --- /dev/null +++ b/apps/documentation/worker-name.ts @@ -0,0 +1,65 @@ +export const DOCUMENTATION_WORKER_NAME = 'devflare-docs' + +const CLOUDFLARE_WORKER_NAME_MAX_LENGTH = 63 + +function sanitizePreviewScope(rawValue: string): string { + let sanitized = rawValue + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + + if (!sanitized) { + sanitized = 'preview' + } + + if (!/^[a-z]/.test(sanitized)) { + sanitized = `b-${sanitized}` + } + + return sanitized +} + +function clampPreviewScope(baseName: string, previewScope: string): string { + const maxPreviewScopeLength = CLOUDFLARE_WORKER_NAME_MAX_LENGTH - baseName.length - 1 + + if (maxPreviewScopeLength < 1) { + throw new Error(`Worker name "${baseName}" leaves no room for a preview scope suffix.`) + } + + const clamped = previewScope.slice(0, maxPreviewScopeLength).replace(/-+$/g, '') + return clamped || 'preview' +} + +function resolveDefaultPreviewScope(): string | undefined { + const previewIdentifier = process.env.DEVFLARE_PREVIEW_IDENTIFIER?.trim() + if (previewIdentifier) { + return previewIdentifier + } + + const previewPr = process.env.DEVFLARE_PREVIEW_PR?.trim() + if (previewPr) { + return `pr-${previewPr}` + } + + const previewBranch = process.env.DEVFLARE_PREVIEW_BRANCH?.trim() + if (previewBranch) { + return previewBranch + } + + return undefined +} + +export function resolveDocumentationWorkerName(previewScope = resolveDefaultPreviewScope()): string { + const resolvedPreviewScope = previewScope?.trim() + if (!resolvedPreviewScope) { + return DOCUMENTATION_WORKER_NAME + } + + const sanitizedPreviewScope = clampPreviewScope( + DOCUMENTATION_WORKER_NAME, + sanitizePreviewScope(resolvedPreviewScope) + ) + + return `${DOCUMENTATION_WORKER_NAME}-${sanitizedPreviewScope}` +} diff --git a/apps/testing/README.md b/apps/testing/README.md new file mode 100644 index 0000000..650a6ef --- /dev/null +++ b/apps/testing/README.md @@ -0,0 +1,109 @@ +# Testing app + +This directory is the repo's real-world Devflare testing project. + +It still covers the full binding matrix, but it now does so with actual Worker +source files instead of a single eager smoke handler that touches every binding +on every public request. + +## What lives here + +- `devflare.config.ts` + - the main worker with the full binding matrix + - guarded smoke routes so accidental public traffic stays cheap and safe + - local Durable Objects for session, collaboration, and lock coordination +- `src/fetch.ts` + - safe status routes (`/`, `/status`, `/health`) + - guarded `POST /smoke` endpoint for intentional verification +- `src/queue.ts` + - queue consumer that records the last processed queue batch in KV +- `src/scheduled.ts` + - cron handler that records the last scheduled invocation in KV +- `src/do.*.ts` + - real Durable Object implementations for local coordination state +- `workers/auth-service` + - sidecar RPC service for auth-style operations +- `workers/search-service` + - sidecar RPC service deployed from the `staging` config, which the main worker then binds to directly by its branch-scoped Worker name + +## Safe-by-default behavior + +Public requests to `/` or `/status` only report binding availability and the +latest smoke/queue/scheduled state stored in KV. + +The expensive or side-effecting operations live behind `POST /smoke`, which is +disabled unless a `SMOKE_KEY` secret is configured and supplied via the +`X-Devflare-Smoke-Key` header. + +That keeps the project deployable as a real app without turning ordinary +requests into surprise browser sessions, vector writes, or outbound email. + +## Deploy order + +This app depends on sidecar Workers. Deploy them before deploying the main app: + +1. `workers/auth-service` (`devflare-testing-auth-service`) +2. `workers/search-service` using its `staging` config (`devflare-testing-search-service`) +3. the main worker in `apps/testing` (`devflare-testing-binding-matrix`) + +## Branch-scoped CI previews + +The PR preview workflow does **not** use `devflare deploy --preview` for the +main worker. + +Cloudflare does not currently generate same-Worker preview URLs for Workers +that implement Durable Objects, and this app uses Durable Objects. + +Instead, the workflow sets `DEVFLARE_PREVIEW_BRANCH`, which gives the auth, +search, and main workers branch-scoped names during CI preview deploys. The +main worker then deploys with `--env preview`, so each PR gets a real, +reachable `workers.dev` URL while the normal local/default names stay unchanged. + +During those branch/PR-scoped preview deploys, Devflare now automatically +omits the shared queue consumers and, by default, the cron trigger from the +deployed Wrangler config. That keeps previews from contending for the globally +shared Cloudflare queue consumer slot or running extra scheduled jobs against +the shared testing resources, without forcing the app config itself to carry +deploy-strategy conditionals. If a preview really should keep its cron +schedule, set `previews.includeCrons: true` in `devflare.config.ts`. + +The config also uses `preview.scope()` for the preview-owned resource names in +KV, D1, R2, queues, Vectorize, Hyperdrive, Browser Rendering, and Analytics +Engine. That keeps the base config exhaustive while letting preview resolution +materialize names like `devflare-testing-cache-kv-preview` automatically. +Service bindings still follow the branch-scoped worker names produced by +`resolveTestingWorkerNames()`, because those are references to other Workers +rather than standalone Cloudflare resource names. + +That shared preview workflow now publishes a GitHub deployment for branch +targets, updates the stable PR comment for PR targets, and on qualifying branch +pushes can refresh both from the same prepared job while still keeping the +later `/status` assertion as the binding-verification step. + +The preview lifecycle now also lives in `.github/workflows/preview.yml`, which +handles branch cleanup, PR-close cleanup, and manual branch cleanup dispatches. +It retires the branch-scoped Workers, deletes preview-owned resources, and +marks matching GitHub deployment feedback plus the shared PR preview comment +sections inactive when those preview scopes are retired. + +If you want a copyable branch-delete cleanup template for same-Worker preview +flows elsewhere in the repo, see +`.github/workflow-examples/branch-preview-cleanup.example.yml`. + +## External prerequisites for the full matrix + +The project structure is real, but some bindings still depend on Cloudflare +account capabilities that cannot be created purely from source code: + +- `r2` + - the target account must have R2 enabled in the Cloudflare dashboard +- `hyperdrive` + - `POSTGRES` must point at a real Hyperdrive config backed by a real + database + - prefer the stable configured name (`devflare-testing`) over a raw id so + Devflare can resolve it for build/deploy flows +- `sendEmail` + - use real sender/destination addresses that match your Email Sending setup + +Everything else in this app is designed so those prerequisites are explicit, +obvious, and isolated behind intentional smoke checks. \ No newline at end of file diff --git a/apps/testing/devflare.config.ts b/apps/testing/devflare.config.ts new file mode 100644 index 0000000..94adb09 --- /dev/null +++ b/apps/testing/devflare.config.ts @@ -0,0 +1,198 @@ +import { defineConfig, preview, ref } from '../../packages/devflare/src/config-entry' +import { resolveTestingWorkerNames } from './worker-names' + +const accountId = ( + globalThis as typeof globalThis & { + process?: { env?: Record } + } +).process?.env?.CLOUDFLARE_ACCOUNT_ID?.trim() +const workerNames = resolveTestingWorkerNames() +const authService = ref( + workerNames.authServiceName, + () => import('./workers/auth-service/devflare.config') +) +const searchService = ref( + workerNames.searchServiceName, + () => import('./workers/search-service/devflare.config') +) +const pv = preview.scope() + +export default defineConfig({ + name: workerNames.mainWorkerName, + compatibilityDate: '2026-04-08', + accountId, + compatibilityFlags: ['nodejs_compat'], + previews: { + includeCrons: false + }, + + // This config is the repo's deploy-oriented, real-world testing app. + // It keeps the exhaustive binding matrix, but pairs it with actual source + // files, sidecar workers, queue/scheduled handlers, and guarded smoke routes + // so public requests stay cheap and safe by default. + bindings: { + kv: { + // devflare-testing-cache-kv + CACHE: pv('devflare-testing-cache-kv'), + // devflare-testing-sessions-kv + SESSIONS: pv('devflare-testing-sessions-kv') + }, + + d1: { + PRIMARY_DB: pv('devflare-testing-primary-db'), + AUDIT_DB: pv('devflare-testing-audit-db'), + REPORTING_DB: pv('devflare-testing-reporting-db') + }, + + r2: { + ASSETS: pv('devflare-testing-assets-bucket'), + ARCHIVE: pv('devflare-testing-archive-bucket') + }, + + durableObjects: { + SESSION_ROOM: 'SessionRoom', + COLLABORATION_STATE: { className: 'CollaborationState' }, + CROSS_WORKER_LOCK: 'CrossWorkerLock' + }, + + queues: { + producers: { + JOBS: pv('devflare-testing-jobs-queue'), + EMAILS: pv('devflare-testing-emails-queue') + }, + consumers: [ + { + queue: pv('devflare-testing-jobs-queue'), + maxBatchSize: 10, + maxBatchTimeout: 5, + maxRetries: 3, + maxConcurrency: 2, + retryDelay: 30, + deadLetterQueue: pv('devflare-testing-jobs-dlq') + }, + { + queue: pv('devflare-testing-emails-queue'), + maxBatchSize: 25, + maxBatchTimeout: 3, + maxRetries: 5, + deadLetterQueue: pv('devflare-testing-emails-dlq') + } + ] + }, + + services: { + AUTH_SERVICE: authService.worker, + ADMIN_RPC: authService.worker('AdminEntrypoint'), + SEARCH_SERVICE: searchService.worker + }, + + ai: { + binding: 'AI' + }, + + vectorize: { + DOCUMENT_INDEX: { + indexName: pv('devflare-testing-document-index') + }, + SEARCH_INDEX: { + indexName: pv('devflare-testing-search-index') + } + }, + + hyperdrive: { + // Requires a real Hyperdrive config backed by a real database. + // Prefer the stable configured name over a raw id so Devflare can resolve it when needed. + // `previewFallback: 'base'` lets preview deploys reuse the base Hyperdrive + // config when no dedicated preview Hyperdrive exists in the account. + POSTGRES: { + name: pv('devflare-testing'), + previewFallback: 'base' + } + }, + + browser: { + BROWSER: pv('devflare-testing-browser') + }, + + analyticsEngine: { + APP_ANALYTICS: { + dataset: pv('devflare-testing-app-analytics') + }, + SEARCH_ANALYTICS: { + dataset: pv('devflare-testing-search-analytics') + } + }, + + sendEmail: { + TRANSACTIONAL_EMAIL: { + allowedDestinationAddresses: ['ops@example.com', 'support@example.com'], + allowedSenderAddresses: ['noreply@example.com'] + }, + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + }, + + vars: { + APP_NAME: 'testing-binding-matrix', + DEPLOYMENT_CHANNEL: 'development', + AI_MODEL: '@cf/meta/llama-3.1-8b-instruct', + BROWSER_TARGET_URL: 'https://example.com/', + MAIL_FROM: 'noreply@example.com', + OPS_EMAIL: 'ops@example.com', + SUPPORT_EMAIL_ADDRESS: 'support@example.com' + }, + + env: { + preview: { + vars: { + APP_NAME: 'testing-binding-matrix-preview', + DEPLOYMENT_CHANNEL: 'preview' + } + }, + production: { + vars: { + APP_NAME: 'testing-binding-matrix-production', + DEPLOYMENT_CHANNEL: 'production' + }, + secrets: { + API_TOKEN: { + required: true + } + }, + bindings: { + kv: { + // devflare-testing-cache-kv-production + CACHE: 'devflare-testing-cache-kv-production' + }, + r2: { + ASSETS: 'devflare-testing-assets-bucket-production' + } + } + } + }, + + secrets: { + API_TOKEN: { + required: false + }, + SMOKE_KEY: { + required: false + }, + OPTIONAL_WEBHOOK_SECRET: { + required: false + } + }, + + triggers: { + crons: ['0 */6 * * *'] + }, + + migrations: [ + { + tag: 'v1', + new_classes: ['SessionRoom', 'CollaborationState', 'CrossWorkerLock'] + } + ] +}) diff --git a/apps/testing/package.json b/apps/testing/package.json new file mode 100644 index 0000000..d7ecd16 --- /dev/null +++ b/apps/testing/package.json @@ -0,0 +1,13 @@ +{ + "name": "testing", + "private": true, + "version": "0.0.1", + "type": "module", + "devDependencies": { + "devflare": "workspace:*", + "testing-auth-service": "workspace:*", + "testing-lock-service": "workspace:*", + "testing-search-service": "workspace:*", + "wrangler": "4.81.1" + } +} \ No newline at end of file diff --git a/apps/testing/src/do.collaboration-state.ts b/apps/testing/src/do.collaboration-state.ts new file mode 100644 index 0000000..8e87300 --- /dev/null +++ b/apps/testing/src/do.collaboration-state.ts @@ -0,0 +1,46 @@ +import { DurableObject } from 'cloudflare:workers' + +interface CollaborationEvent { + actor: string + kind: string + target: string + at: string +} + +interface CollaborationSnapshot { + events: CollaborationEvent[] + updatedAt: string +} + +export class CollaborationState extends DurableObject { + private async readState(): Promise { + return (await this.ctx.storage.get('collaboration-state')) ?? { + events: [], + updatedAt: new Date(0).toISOString() + } + } + + async recordChange(change: Pick): Promise { + const current = await this.readState() + const nextEvent: CollaborationEvent = { + ...change, + at: new Date().toISOString() + } + + const nextState: CollaborationSnapshot = { + events: [...current.events, nextEvent].slice(-10), + updatedAt: nextEvent.at + } + + await this.ctx.storage.put('collaboration-state', nextState) + return nextState + } + + async getSummary(): Promise { + const current = await this.readState() + return { + ...current, + eventCount: current.events.length + } + } +} diff --git a/apps/testing/src/do.cross-worker-lock.ts b/apps/testing/src/do.cross-worker-lock.ts new file mode 100644 index 0000000..42912f9 --- /dev/null +++ b/apps/testing/src/do.cross-worker-lock.ts @@ -0,0 +1,45 @@ +import { DurableObject } from 'cloudflare:workers' + +interface LockSnapshot { + owner: string + expiresAt: number +} + +export class CrossWorkerLock extends DurableObject { + async acquire(owner: string, ttlMs = 60_000): Promise { + const now = Date.now() + const current = await this.ctx.storage.get('cross-worker-lock') + + if (!current || current.expiresAt <= now || current.owner === owner) { + const nextState: LockSnapshot = { + owner, + expiresAt: now + ttlMs + } + + await this.ctx.storage.put('cross-worker-lock', nextState) + return { + acquired: true, + ...nextState + } + } + + return { + acquired: false, + ...current + } + } + + async status(): Promise { + return (await this.ctx.storage.get('cross-worker-lock')) ?? null + } + + async release(owner: string): Promise { + const current = await this.ctx.storage.get('cross-worker-lock') + if (!current || current.owner !== owner) { + return false + } + + await this.ctx.storage.delete('cross-worker-lock') + return true + } +} diff --git a/apps/testing/src/do.session-room.ts b/apps/testing/src/do.session-room.ts new file mode 100644 index 0000000..4530f6e --- /dev/null +++ b/apps/testing/src/do.session-room.ts @@ -0,0 +1,37 @@ +import { DurableObject } from 'cloudflare:workers' + +interface SessionRoomState { + activeMembers: string[] + updatedAt: string +} + +export class SessionRoom extends DurableObject { + private async readState(): Promise { + return (await this.ctx.storage.get('session-room')) ?? { + activeMembers: [], + updatedAt: new Date(0).toISOString() + } + } + + async touchMember(memberId: string): Promise { + const current = await this.readState() + const nextMembers = new Set(current.activeMembers) + nextMembers.add(memberId) + + const nextState: SessionRoomState = { + activeMembers: [...nextMembers].sort(), + updatedAt: new Date().toISOString() + } + + await this.ctx.storage.put('session-room', nextState) + return nextState + } + + async getSummary(): Promise { + const current = await this.readState() + return { + ...current, + memberCount: current.activeMembers.length + } + } +} diff --git a/apps/testing/src/fetch.ts b/apps/testing/src/fetch.ts new file mode 100644 index 0000000..45b2cd5 --- /dev/null +++ b/apps/testing/src/fetch.ts @@ -0,0 +1,650 @@ +import { readJson, stateKeys, writeJson } from './state' + +interface SmokeCheckResult { + ok: boolean + data?: T + error?: string +} + +interface StoredQueueResult { + appName: string + queue: string + messageCount: number + lastMessage: unknown + processedAt: string +} + +interface StoredScheduledResult { + appName: string + cron: string + scheduledTime: number + ranAt: string +} + +interface StoredSmokeResult { + runId: string + startedAt: string + completedAt: string + ok: boolean + results: Record +} + +interface StatusStateRead { + value: T | null + error?: string +} + +interface QueueBinding { + send(message: unknown, options?: unknown): Promise +} + +interface SessionRoomStub { + touchMember(memberId: string): Promise + getSummary(): Promise +} + +interface CollaborationStateStub { + recordChange(change: { actor: string; kind: string; target: string }): Promise + getSummary(): Promise +} + +interface CrossWorkerLockStub { + acquire(owner: string, ttlMs?: number): Promise + status(): Promise +} + +interface DurableObjectNamespaceLike { + getByName(name: string): T +} + +interface AuthServiceRpc { + getServiceInfo(): Promise | unknown + issueServiceToken(subject: string): Promise | unknown +} + +interface AdminRpc { + getHealth(): Promise + runDiagnostics?(): Promise +} + +interface SearchServiceRpc { + getServiceInfo(): Promise | unknown + search(query: string): Promise | unknown +} + +interface VectorizeBinding { + describe?(): Promise + upsert?(vectors: Array<{ id: string; values: number[]; metadata?: Record }>): Promise + query?(vector: number[], options?: { topK?: number; returnMetadata?: boolean }): Promise +} + +interface HyperdriveSocketInfo { + remoteAddress?: string + localAddress?: string +} + +interface HyperdriveSocket { + opened: Promise + close(): Promise +} + +interface HyperdriveBinding { + query?(sql: string): Promise + connect?(): HyperdriveSocket + connectionString?: string + host?: string + port?: number + database?: string +} + +interface BrowserFetchLike { + fetch?(request: Request): Promise +} + +interface AIBinding { + run(model: string, input: unknown, options?: unknown): Promise +} + +interface EmailBinding { + send(message: unknown): Promise +} + +interface AnalyticsBinding { + writeDataPoint(point: unknown): void +} + +interface TestingEnv { + APP_NAME: string + DEPLOYMENT_CHANNEL?: string + AI_MODEL?: string + BROWSER_TARGET_URL?: string + MAIL_FROM?: string + OPS_EMAIL?: string + SUPPORT_EMAIL_ADDRESS?: string + API_TOKEN?: string + OPTIONAL_WEBHOOK_SECRET?: string + SMOKE_KEY?: string + CACHE: KVNamespace + SESSIONS: KVNamespace + PRIMARY_DB: D1Database + AUDIT_DB: D1Database + REPORTING_DB: D1Database + ASSETS: R2Bucket + ARCHIVE: R2Bucket + SESSION_ROOM: DurableObjectNamespaceLike + COLLABORATION_STATE: DurableObjectNamespaceLike + CROSS_WORKER_LOCK: DurableObjectNamespaceLike + JOBS: QueueBinding + EMAILS: QueueBinding + AUTH_SERVICE: AuthServiceRpc + ADMIN_RPC: AdminRpc + SEARCH_SERVICE: SearchServiceRpc + AI: AIBinding + DOCUMENT_INDEX: VectorizeBinding + SEARCH_INDEX: VectorizeBinding + POSTGRES?: HyperdriveBinding + BROWSER?: BrowserFetchLike + APP_ANALYTICS: AnalyticsBinding + SEARCH_ANALYTICS: AnalyticsBinding + TRANSACTIONAL_EMAIL: EmailBinding + SUPPORT_EMAIL: EmailBinding +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + +function truncatePreview(value: unknown): string { + const text = typeof value === 'string' + ? value + : JSON.stringify(value) + + if (!text) return 'null' + return text.length > 240 ? `${text.slice(0, 237)}...` : text +} + +function createBindingsSummary(env: TestingEnv): Record { + return { + kv: { + CACHE: Boolean(env.CACHE), + SESSIONS: Boolean(env.SESSIONS) + }, + d1: { + PRIMARY_DB: Boolean(env.PRIMARY_DB), + AUDIT_DB: Boolean(env.AUDIT_DB), + REPORTING_DB: Boolean(env.REPORTING_DB) + }, + r2: { + ASSETS: Boolean(env.ASSETS), + ARCHIVE: Boolean(env.ARCHIVE) + }, + durableObjects: { + SESSION_ROOM: Boolean(env.SESSION_ROOM), + COLLABORATION_STATE: Boolean(env.COLLABORATION_STATE), + CROSS_WORKER_LOCK: Boolean(env.CROSS_WORKER_LOCK) + }, + queues: { + JOBS: Boolean(env.JOBS), + EMAILS: Boolean(env.EMAILS) + }, + services: { + AUTH_SERVICE: Boolean(env.AUTH_SERVICE), + ADMIN_RPC: Boolean(env.ADMIN_RPC), + SEARCH_SERVICE: Boolean(env.SEARCH_SERVICE) + }, + ai: { + AI: Boolean(env.AI) + }, + vectorize: { + DOCUMENT_INDEX: Boolean(env.DOCUMENT_INDEX), + SEARCH_INDEX: Boolean(env.SEARCH_INDEX) + }, + hyperdrive: { + POSTGRES: Boolean(env.POSTGRES) + }, + browser: { + BROWSER: Boolean(env.BROWSER) + }, + analyticsEngine: { + APP_ANALYTICS: Boolean(env.APP_ANALYTICS), + SEARCH_ANALYTICS: Boolean(env.SEARCH_ANALYTICS) + }, + sendEmail: { + TRANSACTIONAL_EMAIL: Boolean(env.TRANSACTIONAL_EMAIL), + SUPPORT_EMAIL: Boolean(env.SUPPORT_EMAIL) + }, + secrets: { + API_TOKEN: Boolean(env.API_TOKEN), + OPTIONAL_WEBHOOK_SECRET: Boolean(env.OPTIONAL_WEBHOOK_SECRET), + SMOKE_KEY: Boolean(env.SMOKE_KEY) + } + } +} + +async function readStatusState( + getNamespace: () => KVNamespace | undefined, + key: string, + bindingName = 'SESSIONS' +): Promise> { + try { + const namespace = getNamespace() + if (!namespace) { + return { + value: null, + error: `${bindingName} binding is unavailable` + } + } + + return { + value: await readJson(namespace, key) + } + } catch (error) { + return { + value: null, + error: formatError(error) + } + } +} + +async function buildStatusResponse(env: TestingEnv): Promise> { + const [lastSmokeResult, lastQueueJobs, lastQueueEmails, lastScheduledRun] = await Promise.all([ + readStatusState(() => env.SESSIONS, stateKeys.smokeResult), + readStatusState(() => env.SESSIONS, stateKeys.queueJobs), + readStatusState(() => env.SESSIONS, stateKeys.queueEmails), + readStatusState(() => env.SESSIONS, stateKeys.scheduled) + ]) + + const stateReadErrors = Object.fromEntries( + Object.entries({ + lastSmokeResult: lastSmokeResult.error, + lastQueueJobs: lastQueueJobs.error, + lastQueueEmails: lastQueueEmails.error, + lastScheduledRun: lastScheduledRun.error + }).filter(([, error]) => Boolean(error)) + ) + + return { + appName: env.APP_NAME, + deploymentChannel: env.DEPLOYMENT_CHANNEL ?? 'development', + smokeEnabled: Boolean(env.SMOKE_KEY), + routes: { + status: 'GET /status', + health: 'GET /health', + smoke: 'POST /smoke with X-Devflare-Smoke-Key' + }, + hasDurableObjectBindings: Boolean(env.SESSION_ROOM && env.COLLABORATION_STATE && env.CROSS_WORKER_LOCK), + hasServiceBindings: Boolean(env.AUTH_SERVICE && env.ADMIN_RPC && env.SEARCH_SERVICE), + hasVectorizeBindings: Boolean(env.DOCUMENT_INDEX && env.SEARCH_INDEX), + hasAnalyticsBindings: Boolean(env.APP_ANALYTICS && env.SEARCH_ANALYTICS), + hasSendEmailBindings: Boolean(env.TRANSACTIONAL_EMAIL && env.SUPPORT_EMAIL), + hasHyperdriveBinding: Boolean(env.POSTGRES), + bindings: createBindingsSummary(env), + lastSmokeResult: lastSmokeResult.value, + lastQueueJobs: lastQueueJobs.value, + lastQueueEmails: lastQueueEmails.value, + lastScheduledRun: lastScheduledRun.value, + ...(Object.keys(stateReadErrors).length > 0 + ? { + stateReadErrors + } + : {}) + } +} + +function authorizeSmokeRequest(request: Request, env: TestingEnv): { ok: true } | { ok: false; status: number; error: string } { + if (!env.SMOKE_KEY) { + return { + ok: false, + status: 503, + error: 'SMOKE_KEY secret is not configured for this deployment' + } + } + + const provided = request.headers.get('x-devflare-smoke-key') + ?? request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') + + if (provided !== env.SMOKE_KEY) { + return { + ok: false, + status: 401, + error: 'Missing or invalid X-Devflare-Smoke-Key header' + } + } + + return { ok: true } +} + +async function settle(operation: Promise): Promise> { + try { + return { + ok: true, + data: await operation + } + } catch (error) { + return { + ok: false, + error: formatError(error) + } + } +} + +async function smokeKv(env: TestingEnv, runId: string): Promise> { + const cacheKey = `smoke:${runId}:cache` + const sessionKey = `smoke:${runId}:session` + + await env.CACHE.put(cacheKey, runId) + await env.SESSIONS.put(sessionKey, `session-${runId}`) + + return { + cacheKey, + cacheValue: await env.CACHE.get(cacheKey), + sessionValue: await env.SESSIONS.get(sessionKey) + } +} + +async function runD1HealthCheck(database: D1Database): Promise { + const result = await database.prepare('select 1 as ok').first<{ ok: number }>() + return result?.ok === 1 +} + +async function smokeD1(env: TestingEnv): Promise> { + return { + primary: await runD1HealthCheck(env.PRIMARY_DB), + audit: await runD1HealthCheck(env.AUDIT_DB), + reporting: await runD1HealthCheck(env.REPORTING_DB) + } +} + +async function smokeR2(env: TestingEnv, runId: string): Promise> { + const assetKey = `smoke/${runId}/asset.txt` + const archiveKey = `smoke/${runId}/archive.txt` + + await env.ASSETS.put(assetKey, runId) + await env.ARCHIVE.put(archiveKey, runId) + + const assetValue = await env.ASSETS.get(assetKey) + const archiveValue = await env.ARCHIVE.get(archiveKey) + + await env.ASSETS.delete(assetKey) + await env.ARCHIVE.delete(archiveKey) + + return { + assetValue: assetValue ? await assetValue.text() : null, + archiveValue: archiveValue ? await archiveValue.text() : null + } +} + +async function smokeDurableObjects(env: TestingEnv, runId: string): Promise> { + const room = env.SESSION_ROOM.getByName('smoke-room') + const collaboration = env.COLLABORATION_STATE.getByName('smoke-room') + const lock = env.CROSS_WORKER_LOCK.getByName('smoke-lock') + + return { + roomTouch: await room.touchMember(runId), + roomSummary: await room.getSummary(), + collaborationUpdate: await collaboration.recordChange({ + actor: 'smoke-runner', + kind: 'verification', + target: runId + }), + collaborationSummary: await collaboration.getSummary(), + lockAcquire: await lock.acquire(runId), + lockStatus: await lock.status() + } +} + +async function smokeQueues(env: TestingEnv, runId: string): Promise> { + await env.JOBS.send({ + runId, + type: 'job-smoke', + queuedAt: new Date().toISOString() + }) + + await env.EMAILS.send({ + runId, + type: 'email-smoke', + queuedAt: new Date().toISOString() + }) + + return { + enqueuedQueues: ['JOBS', 'EMAILS'] + } +} + +async function smokeServices(env: TestingEnv, runId: string): Promise> { + const [authInfo, token, adminHealth, diagnostics, searchInfo, searchResult] = await Promise.all([ + Promise.resolve(env.AUTH_SERVICE.getServiceInfo()), + Promise.resolve(env.AUTH_SERVICE.issueServiceToken(runId)), + env.ADMIN_RPC.getHealth(), + env.ADMIN_RPC.runDiagnostics?.() ?? null, + Promise.resolve(env.SEARCH_SERVICE.getServiceInfo()), + Promise.resolve(env.SEARCH_SERVICE.search('devflare smoke')) + ]) + + return { + authInfo, + token, + adminHealth, + diagnostics, + searchInfo, + searchResult + } +} + +async function smokeAi(env: TestingEnv): Promise> { + const aiResult = await env.AI.run(env.AI_MODEL ?? '@cf/meta/llama-3.1-8b-instruct', { + messages: [ + { role: 'system', content: 'Reply with OK only.' }, + { role: 'user', content: 'Confirm the testing smoke endpoint is online.' } + ], + max_tokens: 4 + }) + + return { + preview: truncatePreview(aiResult) + } +} + +async function smokeVectorize(env: TestingEnv, runId: string): Promise> { + const vector = Array.from({ length: 32 }, (_, index) => Number(((index + 1) / 100).toFixed(2))) + + return { + documentIndex: await env.DOCUMENT_INDEX.describe?.(), + searchIndex: await env.SEARCH_INDEX.describe?.(), + documentUpsert: await env.DOCUMENT_INDEX.upsert?.([ + { + id: `document-${runId}`, + values: vector, + metadata: { runId, kind: 'document' } + } + ]), + searchUpsert: await env.SEARCH_INDEX.upsert?.([ + { + id: `search-${runId}`, + values: vector, + metadata: { runId, kind: 'search' } + } + ]), + query: await env.SEARCH_INDEX.query?.(vector, { + topK: 1, + returnMetadata: true + }) + } +} + +async function smokeHyperdrive(env: TestingEnv): Promise> { + if (!env.POSTGRES) { + throw new Error('POSTGRES binding is missing') + } + + if (typeof env.POSTGRES.query === 'function') { + return { + mode: 'query', + result: await env.POSTGRES.query('select 1 as ok') + } + } + + if (typeof env.POSTGRES.connect === 'function') { + const socket = env.POSTGRES.connect() + + try { + const opened = await socket.opened + return { + mode: 'socket', + hasConnectionString: Boolean(env.POSTGRES.connectionString), + remoteAddress: opened.remoteAddress ?? null, + localAddress: opened.localAddress ?? null, + host: env.POSTGRES.host ?? null, + port: env.POSTGRES.port ?? null, + database: env.POSTGRES.database ?? null + } + } finally { + await socket.close().catch(() => undefined) + } + } + + throw new Error('Hyperdrive binding does not expose query() or connect()') +} + +async function smokeBrowser(env: TestingEnv): Promise> { + if (!env.BROWSER) { + throw new Error('BROWSER binding is missing') + } + + if (typeof env.BROWSER.fetch !== 'function') { + throw new Error('Browser binding does not expose fetch()') + } + + const response = await env.BROWSER.fetch(new Request(env.BROWSER_TARGET_URL ?? 'https://example.com/')) + return { + mode: 'fetch', + status: response.status, + ok: response.ok + } +} + +async function smokeAnalytics(env: TestingEnv, runId: string): Promise> { + env.APP_ANALYTICS.writeDataPoint({ + indexes: [env.APP_NAME], + blobs: [runId] + }) + + env.SEARCH_ANALYTICS.writeDataPoint({ + indexes: ['search'], + blobs: ['devflare smoke'] + }) + + return { + appAnalytics: true, + searchAnalytics: true + } +} + +async function smokeSendEmail(env: TestingEnv, runId: string): Promise> { + const from = env.MAIL_FROM ?? 'noreply@example.com' + const opsEmail = env.OPS_EMAIL ?? 'ops@example.com' + const supportEmail = env.SUPPORT_EMAIL_ADDRESS ?? 'support@example.com' + + await env.TRANSACTIONAL_EMAIL.send({ + from, + to: opsEmail, + subject: `${env.APP_NAME} smoke run ${runId}`, + text: `Transactional smoke run ${runId}` + }) + + await env.SUPPORT_EMAIL.send({ + from, + to: supportEmail, + subject: `${env.APP_NAME} support smoke`, + text: `Support smoke run ${runId}` + }) + + return { + transactionalTo: opsEmail, + supportTo: supportEmail + } +} + +async function runSmoke(env: TestingEnv): Promise { + const runId = crypto.randomUUID() + const startedAt = new Date().toISOString() + + const checks = { + kv: smokeKv(env, runId), + d1: smokeD1(env), + r2: smokeR2(env, runId), + durableObjects: smokeDurableObjects(env, runId), + queues: smokeQueues(env, runId), + services: smokeServices(env, runId), + ai: smokeAi(env), + vectorize: smokeVectorize(env, runId), + hyperdrive: smokeHyperdrive(env), + browser: smokeBrowser(env), + analytics: smokeAnalytics(env, runId), + sendEmail: smokeSendEmail(env, runId) + } + + const results = Object.fromEntries( + await Promise.all( + Object.entries(checks).map(async ([name, operation]) => [name, await settle(operation)] as const) + ) + ) + + const smokeResult: StoredSmokeResult = { + runId, + startedAt, + completedAt: new Date().toISOString(), + ok: Object.values(results).every((result) => result.ok), + results + } + + await writeJson(env.SESSIONS, stateKeys.smokeResult, smokeResult) + return smokeResult +} + +export async function fetch(request: Request, env: TestingEnv, _ctx: ExecutionContext): Promise { + const url = new URL(request.url) + + if (url.pathname === '/' || url.pathname === '/status') { + return Response.json(await buildStatusResponse(env)) + } + + if (url.pathname === '/health') { + return Response.json({ + ok: true, + appName: env.APP_NAME, + deploymentChannel: env.DEPLOYMENT_CHANNEL ?? 'development' + }) + } + + if (url.pathname === '/smoke' && request.method === 'POST') { + const authorization = authorizeSmokeRequest(request, env) + if (!authorization.ok) { + return Response.json({ + ok: false, + error: authorization.error, + smokeEnabled: Boolean(env.SMOKE_KEY) + }, { + status: authorization.status + }) + } + + const smokeResult = await runSmoke(env) + return Response.json({ + appName: env.APP_NAME, + deploymentChannel: env.DEPLOYMENT_CHANNEL ?? 'development', + ...smokeResult + }) + } + + return Response.json({ + ok: false, + error: 'Not found' + }, { + status: 404 + }) +} \ No newline at end of file diff --git a/apps/testing/src/queue.ts b/apps/testing/src/queue.ts new file mode 100644 index 0000000..7a7ee08 --- /dev/null +++ b/apps/testing/src/queue.ts @@ -0,0 +1,33 @@ +import { stateKeys, writeJson } from './state' + +interface QueueMessage { + body: Body + ack?(): void +} + +interface QueueBatch { + queue: string + messages: QueueMessage[] +} + +interface QueueEnv { + SESSIONS: KVNamespace + APP_NAME: string +} + +export async function queue(batch: QueueBatch, env: QueueEnv, _ctx: ExecutionContext): Promise { + const key = batch.queue.includes('emails') ? stateKeys.queueEmails : stateKeys.queueJobs + const lastMessage = batch.messages.at(-1)?.body ?? null + + await writeJson(env.SESSIONS, key, { + appName: env.APP_NAME, + queue: batch.queue, + messageCount: batch.messages.length, + lastMessage, + processedAt: new Date().toISOString() + }) + + for (const message of batch.messages) { + message.ack?.() + } +} diff --git a/apps/testing/src/scheduled.ts b/apps/testing/src/scheduled.ts new file mode 100644 index 0000000..f1f2883 --- /dev/null +++ b/apps/testing/src/scheduled.ts @@ -0,0 +1,20 @@ +import { stateKeys, writeJson } from './state' + +interface ScheduledControllerLike { + cron: string + scheduledTime?: number +} + +interface ScheduledEnv { + SESSIONS: KVNamespace + APP_NAME: string +} + +export async function scheduled(controller: ScheduledControllerLike, env: ScheduledEnv, _ctx: ExecutionContext): Promise { + await writeJson(env.SESSIONS, stateKeys.scheduled, { + appName: env.APP_NAME, + cron: controller.cron, + scheduledTime: controller.scheduledTime ?? Date.now(), + ranAt: new Date().toISOString() + }) +} diff --git a/apps/testing/src/state.ts b/apps/testing/src/state.ts new file mode 100644 index 0000000..08a8e56 --- /dev/null +++ b/apps/testing/src/state.ts @@ -0,0 +1,21 @@ +export const stateKeys = { + smokeResult: 'testing:smoke:last-result', + queueJobs: 'testing:queue:jobs:last', + queueEmails: 'testing:queue:emails:last', + scheduled: 'testing:scheduled:last-run' +} as const + +export async function readJson(namespace: KVNamespace, key: string): Promise { + const stored = await namespace.get(key) + if (!stored) return null + + try { + return JSON.parse(stored) as T + } catch { + return null + } +} + +export function writeJson(namespace: KVNamespace, key: string, value: unknown): Promise { + return namespace.put(key, JSON.stringify(value)) +} diff --git a/apps/testing/tsconfig.json b/apps/testing/tsconfig.json new file mode 100644 index 0000000..b5817ee --- /dev/null +++ b/apps/testing/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "paths": { + "devflare": ["../../packages/devflare/src/index.ts"], + "devflare/*": ["../../packages/devflare/src/*"] + } + }, + "include": [ + "devflare.config.ts", + "src/**/*.ts", + "workers/**/*.ts", + "workers/**/devflare.config.ts" + ] +} diff --git a/apps/testing/worker-names.ts b/apps/testing/worker-names.ts new file mode 100644 index 0000000..9451db0 --- /dev/null +++ b/apps/testing/worker-names.ts @@ -0,0 +1,52 @@ +const CLOUDFLARE_WORKER_NAME_MAX_LENGTH = 63 + +function sanitizeBranchFragment(rawValue: string): string { + let sanitized = rawValue + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + + if (!sanitized) { + sanitized = 'preview' + } + + if (!/^[a-z]/.test(sanitized)) { + sanitized = `b-${sanitized}` + } + + return sanitized +} + +function clampBranchFragment(baseName: string, branchFragment: string, reservedSuffix: string): string { + const maxBranchLength = CLOUDFLARE_WORKER_NAME_MAX_LENGTH - baseName.length - reservedSuffix.length - 1 + + if (maxBranchLength < 1) { + throw new Error(`Worker name "${baseName}" leaves no room for a branch-scoped preview suffix.`) + } + + const clamped = branchFragment.slice(0, maxBranchLength).replace(/-+$/g, '') + return clamped || 'preview' +} + +function buildTestingWorkerName(baseName: string, branchName?: string, reservedSuffix = ''): string { + if (!branchName?.trim()) { + return baseName + } + + const branchFragment = clampBranchFragment( + baseName, + sanitizeBranchFragment(branchName), + reservedSuffix + ) + + return `${baseName}-${branchFragment}` +} + +export function resolveTestingWorkerNames(branchName = process.env.DEVFLARE_PREVIEW_BRANCH) { + return { + authServiceName: buildTestingWorkerName('devflare-testing-auth-service', branchName), + searchServiceName: buildTestingWorkerName('devflare-testing-search-service', branchName, '-staging'), + mainWorkerName: buildTestingWorkerName('devflare-testing-binding-matrix', branchName, '-preview') + } +} \ No newline at end of file diff --git a/apps/testing/workers/auth-service/devflare.config.ts b/apps/testing/workers/auth-service/devflare.config.ts new file mode 100644 index 0000000..deeeadc --- /dev/null +++ b/apps/testing/workers/auth-service/devflare.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '../../../../packages/devflare/src/config-entry' +import { resolveTestingWorkerNames } from '../../worker-names' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: resolveTestingWorkerNames().authServiceName, + compatibilityDate: '2026-04-08', + accountId, + files: { + fetch: 'src/worker.ts' + } +}) diff --git a/apps/testing/workers/auth-service/package.json b/apps/testing/workers/auth-service/package.json new file mode 100644 index 0000000..8ebf8fe --- /dev/null +++ b/apps/testing/workers/auth-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "testing-auth-service", + "private": true, + "version": "0.0.1", + "type": "module", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1" + } +} \ No newline at end of file diff --git a/apps/testing/workers/auth-service/src/ep.admin.ts b/apps/testing/workers/auth-service/src/ep.admin.ts new file mode 100644 index 0000000..f684df8 --- /dev/null +++ b/apps/testing/workers/auth-service/src/ep.admin.ts @@ -0,0 +1,27 @@ +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class AdminEntrypoint extends WorkerEntrypoint { + async getHealth(): Promise<{ + status: 'healthy' + service: string + checkedAt: string + }> { + return { + status: 'healthy', + service: 'devflare-testing-auth-service', + checkedAt: new Date().toISOString() + } + } + + async runDiagnostics(): Promise<{ + service: string + queueBacklog: number + sessionCount: number + }> { + return { + service: 'devflare-testing-auth-service', + queueBacklog: 0, + sessionCount: 0 + } + } +} diff --git a/apps/testing/workers/auth-service/src/worker.ts b/apps/testing/workers/auth-service/src/worker.ts new file mode 100644 index 0000000..45d555f --- /dev/null +++ b/apps/testing/workers/auth-service/src/worker.ts @@ -0,0 +1,28 @@ +export interface IssuedServiceToken { + subject: string + token: string + scopes: string[] + issuedAt: string +} + +export function getServiceInfo(): { + service: string + version: string + capabilities: string[] +} { + return { + service: 'devflare-testing-auth-service', + version: '1.0.0', + capabilities: ['getServiceInfo', 'issueServiceToken'] + } +} + +export function issueServiceToken(subject: string): IssuedServiceToken { + const trimmedSubject = subject.trim() || 'anonymous' + return { + subject: trimmedSubject, + token: `testing-token-${trimmedSubject}`, + scopes: ['smoke:run', 'service:read'], + issuedAt: new Date().toISOString() + } +} diff --git a/apps/testing/workers/lock-service/devflare.config.ts b/apps/testing/workers/lock-service/devflare.config.ts new file mode 100644 index 0000000..df21f13 --- /dev/null +++ b/apps/testing/workers/lock-service/devflare.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '../../../../packages/devflare/src/config-entry' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: 'devflare-testing-shared-worker', + compatibilityDate: '2026-04-08', + accountId, + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + CROSS_WORKER_LOCK: 'CrossWorkerLock' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['CrossWorkerLock'] + } + ] +}) diff --git a/apps/testing/workers/lock-service/package.json b/apps/testing/workers/lock-service/package.json new file mode 100644 index 0000000..c342d2e --- /dev/null +++ b/apps/testing/workers/lock-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "testing-lock-service", + "private": true, + "version": "0.0.1", + "type": "module", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1" + } +} \ No newline at end of file diff --git a/apps/testing/workers/lock-service/src/do.cross-worker-lock.ts b/apps/testing/workers/lock-service/src/do.cross-worker-lock.ts new file mode 100644 index 0000000..42912f9 --- /dev/null +++ b/apps/testing/workers/lock-service/src/do.cross-worker-lock.ts @@ -0,0 +1,45 @@ +import { DurableObject } from 'cloudflare:workers' + +interface LockSnapshot { + owner: string + expiresAt: number +} + +export class CrossWorkerLock extends DurableObject { + async acquire(owner: string, ttlMs = 60_000): Promise { + const now = Date.now() + const current = await this.ctx.storage.get('cross-worker-lock') + + if (!current || current.expiresAt <= now || current.owner === owner) { + const nextState: LockSnapshot = { + owner, + expiresAt: now + ttlMs + } + + await this.ctx.storage.put('cross-worker-lock', nextState) + return { + acquired: true, + ...nextState + } + } + + return { + acquired: false, + ...current + } + } + + async status(): Promise { + return (await this.ctx.storage.get('cross-worker-lock')) ?? null + } + + async release(owner: string): Promise { + const current = await this.ctx.storage.get('cross-worker-lock') + if (!current || current.owner !== owner) { + return false + } + + await this.ctx.storage.delete('cross-worker-lock') + return true + } +} diff --git a/apps/testing/workers/lock-service/src/fetch.ts b/apps/testing/workers/lock-service/src/fetch.ts new file mode 100644 index 0000000..af45573 --- /dev/null +++ b/apps/testing/workers/lock-service/src/fetch.ts @@ -0,0 +1,10 @@ +export { CrossWorkerLock } from './do.cross-worker-lock' + +export default { + async fetch(): Promise { + return Response.json({ + ok: true, + worker: 'devflare-testing-shared-worker' + }) + } +} diff --git a/apps/testing/workers/search-service/devflare.config.ts b/apps/testing/workers/search-service/devflare.config.ts new file mode 100644 index 0000000..f5bd3c6 --- /dev/null +++ b/apps/testing/workers/search-service/devflare.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '../../../../packages/devflare/src/config-entry' +import { resolveTestingWorkerNames } from '../../worker-names' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: resolveTestingWorkerNames().searchServiceName, + compatibilityDate: '2026-04-08', + accountId, + files: { + fetch: 'src/worker.ts' + }, + env: { + staging: { + vars: { + SERVICE_CHANNEL: 'staging' + } + } + } +}) diff --git a/apps/testing/workers/search-service/package.json b/apps/testing/workers/search-service/package.json new file mode 100644 index 0000000..2551027 --- /dev/null +++ b/apps/testing/workers/search-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "testing-search-service", + "private": true, + "version": "0.0.1", + "type": "module", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1" + } +} \ No newline at end of file diff --git a/apps/testing/workers/search-service/src/worker.ts b/apps/testing/workers/search-service/src/worker.ts new file mode 100644 index 0000000..c346a8c --- /dev/null +++ b/apps/testing/workers/search-service/src/worker.ts @@ -0,0 +1,34 @@ +export interface SearchHit { + id: string + title: string + score: number +} + +export function getServiceInfo(): { + service: string + channel: string + indexedCollections: string[] +} { + return { + service: 'devflare-testing-search-service', + channel: 'staging', + indexedCollections: ['documents', 'search'] + } +} + +export function search(query: string): { + query: string + results: SearchHit[] +} { + const normalized = query.trim() || 'devflare' + return { + query: normalized, + results: [ + { + id: 'devflare-testing', + title: `Result for ${normalized}`, + score: 0.99 + } + ] + } +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..6a08dd5 --- /dev/null +++ b/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": "warn" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded", + "trailingCommas": "none" + } + } +} \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a2c88d7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1874 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "devflare-monorepo", + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@cloudflare/workers-types": "^4.20260426.1", + "@types/bun": "^1.3.13", + "@typescript/native-preview": "^7.0.0-dev.20260426.1", + "devflare": "workspace:*", + "turbo": "^2.9.6", + "typescript": "^5.9.3", + }, + }, + "apps/documentation": { + "name": "documentation", + "version": "0.0.1", + "dependencies": { + "@chenglou/pretext": "^0.0.5", + "floating-runes": "^1.4.0", + "prismjs": "^1.30.0", + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@iconify-json/fluent": "^1.2.44", + "@iconify-json/logos": "^1.2.11", + "@iconify-json/material-icon-theme": "^1.2.58", + "@iconify-json/twemoji": "^1.2.5", + "@iconify/tailwind4": "^1.2.3", + "@inlang/paraglide-js": "^2.15.2", + "@sveltejs/adapter-cloudflare": "^7.2.8", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/prismjs": "^1.26.6", + "devflare": "workspace:*", + "puppeteer-core": "^24.40.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7", + "wrangler": "^4.81.0", + }, + }, + "apps/testing": { + "name": "testing", + "version": "0.0.1", + "devDependencies": { + "devflare": "workspace:*", + "testing-auth-service": "workspace:*", + "testing-lock-service": "workspace:*", + "testing-search-service": "workspace:*", + "wrangler": "4.81.1", + }, + }, + "apps/testing/workers/auth-service": { + "name": "testing-auth-service", + "version": "0.0.1", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1", + }, + }, + "apps/testing/workers/lock-service": { + "name": "testing-lock-service", + "version": "0.0.1", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1", + }, + }, + "apps/testing/workers/search-service": { + "name": "testing-search-service", + "version": "0.0.1", + "devDependencies": { + "devflare": "workspace:*", + "wrangler": "4.81.1", + }, + }, + "cases/case1": { + "name": "@devflare/case1-basic-worker", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case10": { + "name": "@devflare/case10-path-aliases", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case11": { + "name": "@devflare/case11-cross-package-do", + "version": "0.0.1", + "dependencies": { + "@devflare/case11-do-shared": "workspace:*", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case11-do-shared": { + "name": "@devflare/case11-do-shared", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case12": { + "name": "@devflare/case12-email-handlers", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "mimetext": "^3.0.24", + "postal-mime": "^2.4.1", + "typescript": "^5.7.2", + }, + }, + "cases/case13": { + "name": "@devflare/case13-tail-workers", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case14": { + "name": "@devflare/case14-hyperdrive", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case15": { + "name": "@devflare/case15-ai-vectorize", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case16": { + "name": "@devflare/case16-workflows", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case17": { + "name": "@devflare/case17-rolldown-plugin", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + "vite": "^6.4.0", + }, + }, + "cases/case18": { + "name": "@devflare/case18-sveltekit-full", + "version": "0.0.1", + "dependencies": { + "@cloudflare/puppeteer": "^1.0.4", + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260317.1", + "@sveltejs/adapter-cloudflare": "^6.0.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "wrangler": "^4.0.0", + }, + }, + "cases/case19": { + "name": "@devflare/case19-transport-do-rpc", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case3": { + "name": "@devflare/case3-durable-objects", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case5": { + "name": "@devflare/case5-multi-worker", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case6": { + "name": "@devflare/case6-queues-crons", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case7": { + "name": "@devflare/case7-edge-cases", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2", + }, + }, + "cases/case8": { + "name": "@devflare/case8-file-routing", + "version": "0.0.1", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case9": { + "name": "@devflare/case9-monorepo", + "version": "0.0.1", + "dependencies": { + "@devflare/case9-shared": "workspace:*", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + }, + }, + "cases/case9-shared": { + "name": "@devflare/case9-shared", + "version": "0.0.1", + }, + "packages/devflare": { + "name": "devflare", + "version": "1.0.0-next.22", + "bin": { + "devflare": "./bin/devflare.js", + }, + "dependencies": { + "@puppeteer/browsers": "^2.10.3", + "c12": "^2.0.1", + "chokidar": "^4.0.3", + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "es-module-lexer": "^1.6.0", + "execa": "^9.5.2", + "fast-glob": "^3.3.3", + "globby": "^16.1.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^0.30.17", + "miniflare": "^4.20260424.0", + "pathe": "^2.0.2", + "picomatch": "^4.0.3", + "puppeteer-core": "^24.5.0", + "rolldown": "^1.0.0-rc.12", + "smol-toml": "^1.6.1", + "wrangler": "^4.85.0", + "ws": "^8.19.0", + "zod": "^3.25.0", + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260426.1", + "@types/bun": "^1.1.14", + "@types/picomatch": "^4.0.2", + "@types/ws": "^8.18.1", + "typescript": "^5.7.2", + "vite": "^6.0.0", + }, + "peerDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260426.1", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + }, + "optionalPeers": [ + "@cloudflare/vite-plugin", + "@cloudflare/workers-types", + "vite", + ], + }, + }, + "overrides": { + "unicorn-magic": "^0.4.0", + }, + "packages": { + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.2", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw=="], + + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + + "@chenglou/pretext": ["@chenglou/pretext@0.0.5", "", {}, "sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/puppeteer": ["@cloudflare/puppeteer@1.0.7", "", { "dependencies": { "@puppeteer/browsers": "2.2.4", "debug": "^4.3.5", "devtools-protocol": "0.0.1299070", "ws": "^8.18.0" } }, "sha512-8kjmXjNoS2C1iOMcSmL+If4AOOH2ADbGhyI2V94DJSmuBrUKHZSVcCp6UJjojcCG9dLNNE27SabpRrqIGETF0w=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + + "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.31.2", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.0", "miniflare": "4.20260409.0", "unenv": "2.0.0-rc.24", "wrangler": "4.81.1", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" } }, "sha512-6RyoPhqmpuHPB+Zudt7mOUdGzB1+DQtJtPdAxUajhlS2ZUU0+bCn9Cj4g6Z2EvajBrkBTw1yVLqtt4bsUnp1Ng=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260426.1", "", {}, "sha512-cBYeQaWwv/jFV8ualmwp6wIxmAf0rDe2DPPQwPbslKmPHqgv861YpAvm45r05K40QboZgxNQVIPgNkmtHqZeJQ=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@cyberalien/svg-utils": ["@cyberalien/svg-utils@1.2.8", "", { "dependencies": { "@iconify/types": "^2.0.0" } }, "sha512-ILHRhyyv7WamaiKjPPUqriQKySGnl/r+A6YddAmtvW6xC/f0TksPmhljo/qvqaq7FPJ/ZHvZKsBJeuKOAEGXKA=="], + + "@devflare/case1-basic-worker": ["@devflare/case1-basic-worker@workspace:cases/case1"], + + "@devflare/case10-path-aliases": ["@devflare/case10-path-aliases@workspace:cases/case10"], + + "@devflare/case11-cross-package-do": ["@devflare/case11-cross-package-do@workspace:cases/case11"], + + "@devflare/case11-do-shared": ["@devflare/case11-do-shared@workspace:cases/case11-do-shared"], + + "@devflare/case12-email-handlers": ["@devflare/case12-email-handlers@workspace:cases/case12"], + + "@devflare/case13-tail-workers": ["@devflare/case13-tail-workers@workspace:cases/case13"], + + "@devflare/case14-hyperdrive": ["@devflare/case14-hyperdrive@workspace:cases/case14"], + + "@devflare/case15-ai-vectorize": ["@devflare/case15-ai-vectorize@workspace:cases/case15"], + + "@devflare/case16-workflows": ["@devflare/case16-workflows@workspace:cases/case16"], + + "@devflare/case17-rolldown-plugin": ["@devflare/case17-rolldown-plugin@workspace:cases/case17"], + + "@devflare/case18-sveltekit-full": ["@devflare/case18-sveltekit-full@workspace:cases/case18"], + + "@devflare/case19-transport-do-rpc": ["@devflare/case19-transport-do-rpc@workspace:cases/case19"], + + "@devflare/case3-durable-objects": ["@devflare/case3-durable-objects@workspace:cases/case3"], + + "@devflare/case5-multi-worker": ["@devflare/case5-multi-worker@workspace:cases/case5"], + + "@devflare/case6-queues-crons": ["@devflare/case6-queues-crons@workspace:cases/case6"], + + "@devflare/case7-edge-cases": ["@devflare/case7-edge-cases@workspace:cases/case7"], + + "@devflare/case8-file-routing": ["@devflare/case8-file-routing@workspace:cases/case8"], + + "@devflare/case9-monorepo": ["@devflare/case9-monorepo@workspace:cases/case9"], + + "@devflare/case9-shared": ["@devflare/case9-shared@workspace:cases/case9-shared"], + + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@iconify-json/fluent": ["@iconify-json/fluent@1.2.44", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-cIAVpL2+maZCgKqOzL1ko91p8lviAzFXMI9ha+bnd0x/HaAnSS32Vq0f692CscbIFdev5Y/zW4EPXjsLh5FCHA=="], + + "@iconify-json/logos": ["@iconify-json/logos@1.2.11", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ=="], + + "@iconify-json/material-icon-theme": ["@iconify-json/material-icon-theme@1.2.58", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-G+Xgd6myxrm+zRISSwgrU/6+I7j84qjdguNOEST2V/faow15/c6tmv2/pHxR9W2EYbVQznSk8sYa2Qk2zfEjpw=="], + + "@iconify-json/twemoji": ["@iconify-json/twemoji@1.2.5", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-uKpuIEV0v6K5BW3Mjdyl+XKFVAbbcPxAgifKvEMtZoUZB5+YiY5zaMm2uNNCxyXzAWU9yNLlj41WU6/mvgALsw=="], + + "@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="], + + "@iconify/tools": ["@iconify/tools@5.0.11", "", { "dependencies": { "@cyberalien/svg-utils": "^1.2.8", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0", "fflate": "^0.8.2", "modern-tar": "^0.7.6", "pathe": "^2.0.3", "svgo": "^4.0.1" } }, "sha512-zur/06/zTSflUSoPARK5FfHNZQ9UYsoloPDQHLAZHbQqWhs0/tXS+KB70uOAt94dUB1F94JOkSqIOT2R4Deixg=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@inlang/paraglide-js": ["@inlang/paraglide-js@2.15.3", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.9.1", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "bin": { "paraglide-js": "bin/run.js" } }, "sha512-gneANUhYEPnSjxbKp3QCwmMqQecG+1QWuJSAl3jiPprn2+LeaZu3BgnofRKpo8gkYzB6oE3AY2ecZBXu3UrpOw=="], + + "@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="], + + "@inlang/sdk": ["@inlang/sdk@2.9.1", "", { "dependencies": { "@lix-js/sdk": "0.4.9", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^13.0.0" } }, "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@lix-js/sdk": ["@lix-js/sdk@0.4.9", "", { "dependencies": { "@lix-js/server-protocol-schema": "0.1.1", "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A=="], + + "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.31.28", "", {}, "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-cloudflare": ["@sveltejs/adapter-cloudflare@6.0.1", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250312.0", "esbuild": "^0.24.0", "worktop": "0.8.0-next.18" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0", "wrangler": "^3.87.0 || ^4.0.0" } }, "sha512-peHS0P9UKwqA7LODR6nKUumq3vJym8aJebY/LUSzmcf963j4cIS9G0CHmeazOt1CenjjuejO7AufxzRKPyb1iQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.57.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], + + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260426.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260426.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260426.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260426.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260426.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260426.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260426.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260426.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-zE7B6TIG4XDYr4Your5E2Bxm1vD2YiPyD8OFG4nD5Odt/uN6gO0Y+T4TIbtGUBmOftMRqEV2Jw1ZC4ka0my1yw=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260426.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HzGvERpIFO7p6pMljPN1fIOHqAv2oMeVIqYLSt27TKILkTRpe7fANW3R2OAM+/A+pLtYNNXGDbKl/wR+DHz9KA=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260426.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aE17wCPNQ09K4jV7TQYYRYF/Q/6nFS9jLpbyTYHtS+i+0yV1Rrs4VsqboisS1R/iSWsq3m1Yhh3uS4x3/9KUkg=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260426.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/XJRC8B6JeOOb2/iek/BrzW4r5Nut+fkucG7ntEOQn63IRTsfP+AfJdJodG1VIwXOleNlFgG4RtYTUsvcbDJhg=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260426.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6OfhODChD1N6FX+ITzA1lny3WX6uew/Nw9kN7uWhymXlM3/vE0qtaAfsMpgdHdCbTPgcdpGaNFhbcMieju9Vdg=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260426.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KPDpjmLo/4xY8ugfMGFm7Ona/1igPzZveLt/C0rb6/jNPYuShumRfKYnItGDRXBlmecJY/04lrqkWqQjhtSSPg=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260426.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-I7ThiopxuNKX/iAcwgMwsm6L32GOwmwLOyPwQmXjh5c3VD2acq3FYyZRDJVk0aUUy1w6bTbODlo5ZHoPnlZtvw=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260426.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4624MJq72vN4H1msiWVBqAIyerJRi5Ni/U6eeE1A1Opqg4c4QoalYQQ+5h5RIuaZ6rY+9kvUn+SjsvbZwyLbjQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.7.0", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA=="], + + "bare-os": ["bare-os@3.8.7", "", {}, "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.13.0", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA=="], + + "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "basic-ftp": ["basic-ftp@5.2.2", "", {}, "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "c12": ["c12@2.0.4", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.1.8", "defu": "^6.1.4", "dotenv": "^16.4.7", "giget": "^1.2.4", "jiti": "^2.4.2", "mlly": "^1.7.4", "ohash": "^2.0.4", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^1.3.1", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-3DbbhnFt0fKJHxU4tEUPmD1ahWE4PWPMomqfYsTJdrhpmEnRKJi3qSC4rO5U6E6zN1+pjBY7+z8fUmNRMaVKLw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "core-js-pure": ["core-js-pure@3.49.0", "", {}, "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="], + + "devflare": ["devflare@workspace:packages/devflare"], + + "devtools-protocol": ["devtools-protocol@0.0.1299070", "", {}, "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg=="], + + "documentation": ["documentation@workspace:apps/documentation"], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "floating-runes": ["floating-runes@1.4.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.12" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-wPSP72r+UFpRsCdtGlXojaEtU17Ph7lY3oxOgzPLbGYM921sXyz6xgHTVyMsxDM6bGFAuONX+llnCd8/BbzdHg=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + + "js-sha256": ["js-sha256@0.11.1", "", {}, "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimetext": ["mimetext@3.0.28", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g=="], + + "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "puppeteer-core": ["puppeteer-core@24.40.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1581282", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="], + + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "svelte": ["svelte@5.55.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ=="], + + "svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="], + + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "testing": ["testing@workspace:apps/testing"], + + "testing-auth-service": ["testing-auth-service@workspace:apps/testing/workers/auth-service"], + + "testing-lock-service": ["testing-lock-service@workspace:apps/testing/workers/lock-service"], + + "testing-search-service": ["testing-search-service@workspace:apps/testing/workers/search-service"], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], + + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + + "worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="], + + "wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + + "@cloudflare/puppeteer/@puppeteer/browsers": ["@puppeteer/browsers@2.2.4", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.2", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw=="], + + "@cloudflare/vite-plugin/miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="], + + "@cloudflare/vite-plugin/wrangler": ["wrangler@4.81.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260409.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260409.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260409.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w=="], + + "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@inlang/paraglide-js/consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], + + "@lix-js/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "@sveltejs/adapter-cloudflare/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260410.1", "", {}, "sha512-dPZT4aXxwhGHFhWA9iZhWVfFoO8g9exiLzeaS8y43Dw0Sard6Gb3o5LJjReav3ejHbQLHUfGEiZsRPGW8qmgMg=="], + + "@sveltejs/adapter-cloudflare/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "documentation/@sveltejs/adapter-cloudflare": ["@sveltejs/adapter-cloudflare@7.2.8", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250507.0", "worktop": "0.8.0-next.18" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0", "wrangler": "^4.0.0" } }, "sha512-bIdhY/Fi4AQmqiBdQVKnafH1h9Gw+xbCvHyUu4EouC8rJOU02zwhi14k/FDhQ0mJF1iblIu3m8UNQ8GpGIvIOQ=="], + + "documentation/@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], + + "documentation/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "documentation/vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + + "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], + + "testing/wrangler": ["wrangler@4.81.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260409.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260409.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260409.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w=="], + + "testing-auth-service/wrangler": ["wrangler@4.81.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260409.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260409.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260409.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w=="], + + "testing-lock-service/wrangler": ["wrangler@4.81.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260409.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260409.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260409.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w=="], + + "testing-search-service/wrangler": ["wrangler@4.81.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260409.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260409.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260409.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w=="], + + "wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "@cloudflare/vite-plugin/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "@cloudflare/vite-plugin/miniflare/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "@cloudflare/vite-plugin/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "@cloudflare/vite-plugin/wrangler/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "@sveltejs/adapter-cloudflare/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "documentation/@sveltejs/adapter-cloudflare/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260410.1", "", {}, "sha512-dPZT4aXxwhGHFhWA9iZhWVfFoO8g9exiLzeaS8y43Dw0Sard6Gb3o5LJjReav3ejHbQLHUfGEiZsRPGW8qmgMg=="], + + "testing-auth-service/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "testing-auth-service/wrangler/miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="], + + "testing-auth-service/wrangler/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "testing-lock-service/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "testing-lock-service/wrangler/miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="], + + "testing-lock-service/wrangler/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "testing-search-service/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "testing-search-service/wrangler/miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="], + + "testing-search-service/wrangler/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "testing/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "testing/wrangler/miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="], + + "testing/wrangler/workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="], + + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "testing-auth-service/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "testing-auth-service/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "testing-auth-service/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "testing-auth-service/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "testing-auth-service/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "testing-auth-service/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "testing-auth-service/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "testing-auth-service/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "testing-lock-service/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "testing-lock-service/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "testing-lock-service/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "testing-lock-service/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "testing-lock-service/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "testing-lock-service/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "testing-lock-service/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "testing-lock-service/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + + "testing-search-service/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "testing-search-service/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "testing-search-service/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "testing-search-service/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "testing-search-service/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "testing-search-service/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "testing-search-service/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "testing-search-service/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "testing-search-service/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "testing-search-service/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "testing-search-service/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "testing-search-service/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "testing-search-service/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "testing-search-service/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "testing-search-service/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "testing-search-service/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + + "testing/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "testing/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "testing/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "testing/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "testing/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "testing/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "testing/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "testing/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "testing/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "testing/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "testing/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "testing/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "testing/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "testing/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "testing/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "testing/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "testing/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "testing/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "testing/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "testing/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "testing/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "testing/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "testing/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "testing/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "testing/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "testing/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "testing/wrangler/miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "testing/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "testing/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260409.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w=="], + + "testing/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260409.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ=="], + + "testing/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260409.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg=="], + + "testing/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260409.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg=="], + + "testing/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260409.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q=="], + } +} diff --git a/cases/README.md b/cases/README.md new file mode 100644 index 0000000..8c8790b --- /dev/null +++ b/cases/README.md @@ -0,0 +1,211 @@ +# Case Example Catalog + +The `cases/case*` packages are runnable examples and regression fixtures. Use +them after the docs recipe works and you want a complete package to inspect. + +Run one case from its package root: + +```bash +cd cases/case1 +bun test +``` + +Run all case packages from the workspace root: + +```bash +bun test --filter "case*" +``` + +## Quick Reference + +| Case | Name | Primary proof | Docs | Support status | +| --- | --- | --- | --- | --- | +| 1 | [Basic Worker](#case-1-basic-worker) | Worker routes, env, generated types | `/docs/first-worker`, `/docs/first-route-tree` | Full local | +| 3 | [Durable Objects](#case-3-durable-objects) | DO config, RPC, WebSockets | `/docs/bindings/durable-objects` | Full local | +| 5 | [Multi-Worker](#case-5-multi-worker) | Service bindings and `ref()` | `/docs/bindings/services`, `/docs/multi-workers` | Full local | +| 6 | [Queues & Crons](#case-6-queues--crons) | Queue and scheduled triggers | `/docs/bindings/queues` | Full local | +| 7 | [Edge Cases](#case-7-edge-cases) | Runtime edge coverage | `/docs/docs-release-gates` | Internal regression | +| 8 | [Route Modules](#case-8-route-modules) | Route file dispatch | `/docs/first-route-tree`, `/docs/http-routing` | Full local | +| 9 | [Monorepo](#case-9-monorepo) | Workspace package boundaries | `/docs/monorepo-turborepo` | Full local | +| 10 | [Path Aliases](#case-10-path-aliases) | TS path alias handling | `/docs/project-architecture` | Full local | +| 11 | [Cross-Package DO](#case-11-cross-package-do) | DO binding across packages | `/docs/bindings/durable-objects` | Full local | +| 12 | [Email Handlers](#case-12-email-handlers) | `cf.email.send()` and handler tests | `/docs/bindings/send-email` | Full helper coverage with ingress caveat | +| 13 | [Tail Workers](#case-13-tail-workers) | `cf.tail.trigger()` | `/docs/create-test-context` | Full helper coverage | +| 14 | [Hyperdrive](#case-14-hyperdrive) | Hyperdrive local connection string and binding surface | `/docs/bindings/hyperdrive` | Full local with local DB connection string; hosted pooling caveat | +| 15 | [Vectorize & AI](#case-15-vectorize--ai) | Remote-gated AI and Vectorize | `/docs/bindings/ai`, `/docs/bindings/vectorize` | Remote-gated | +| 16 | [Workflows](#case-16-workflows) | Workflow classes and transport | `/docs/bindings/workflows` | Full local workflow class coverage; hosted lifecycle caveat | +| 17 | [Plugin Namespace Example](#case-17-plugin-namespace-example) | Rolldown plugin namespace behavior | `/docs/project-architecture` | Internal regression | +| 18 | [SvelteKit DO](#case-18-sveltekit-do) | SvelteKit platform plus DO binding | `/docs/sveltekit-with-devflare` | Full local | +| 19 | [Transport & DO RPC](#case-19-transport--do-rpc) | Custom class transport over DO RPC | `/docs/transport-file`, `/docs/bindings/durable-objects` | Full local | + +## Shared Shape + +Most cases follow this package shape: + +```text +caseN/ + devflare.config.ts + package.json + tsconfig.json + env.d.ts + src/ + tests/ +``` + +Generated Devflare and Wrangler outputs belong under `.devflare/` and +`.wrangler/`. Case roots should not keep generated Wrangler files as source. + +## Case Details + +### Case 1: Basic Worker + +- Purpose: smallest Worker package with route modules and generated env types. +- File map: `devflare.config.ts`, `src/fetch.ts`, `src/routes/**`, `tests/**`. +- Run command: `cd cases/case1 && bun test`. +- What it proves: the first Worker shape can run locally with routes and typed env. +- Docs links: `/docs/first-worker`, `/docs/first-route-tree`, `/docs/test-helper-reference`. +- Support status: full local example. + +### Case 3: Durable Objects + +- Purpose: Durable Object authoring, migration config, RPC-style calls, and WebSocket paths. +- File map: root worker files plus `do-service/do.*.ts` and generated types. +- Run command: `cd cases/case3 && bun test`. +- What it proves: DO bindings can be configured, generated, and exercised locally. +- Docs links: `/docs/bindings/durable-objects`, `/docs/transport-file`. +- Support status: full local example. + +### Case 5: Multi-Worker + +- Purpose: service bindings between a gateway Worker and a referenced math Worker. +- File map: `devflare.config.ts`, `src/fetch.ts`, `math-service/devflare.config.ts`, `math-service/worker.ts`, `math-service/ep.admin.ts`, `tests/**`. +- Run command: `cd cases/case5 && bun test`. +- What it proves: `ref()` and service binding RPC work through the local harness. +- Docs links: `/docs/bindings/services`, `/docs/multi-workers`. +- Support status: full local example; still inspect generated Wrangler output for deployment-critical entrypoint names. + +### Case 6: Queues & Crons + +- Purpose: queue consumer and scheduled handler coverage in one package. +- File map: `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, `src/lib/**`, `tests/queues.test.ts`. +- Run command: `cd cases/case6 && bun test`. +- What it proves: `cf.queue.trigger()` and `cf.scheduled.trigger()` can drive local handler behavior. +- Docs links: `/docs/bindings/queues`, `/docs/create-test-context`. +- Support status: full local example; production retry timing still belongs to Cloudflare. + +### Case 7: Edge Cases + +- Purpose: regression coverage for less common runtime and config behavior. +- File map: `src/fetch.ts`, `tests/edge-cases.test.ts`. +- Run command: `cd cases/case7 && bun test`. +- What it proves: selected edge behavior stays covered while public docs stay recipe-first. +- Docs links: `/docs/docs-release-gates`. +- Support status: internal regression case. + +### Case 8: Route Modules + +- Purpose: route tree dispatch with static, dynamic, and catch-all paths. +- File map: `src/fetch.ts`, `src/routes/index.ts`, `src/routes/users/[id].ts`, `src/routes/api/[...path].ts`, `tests/routing.test.ts`. +- Run command: `cd cases/case8 && bun test`. +- What it proves: route modules and manual dispatch patterns work locally. +- Docs links: `/docs/first-route-tree`, `/docs/http-routing`. +- Support status: full local example. + +### Case 9: Monorepo + +- Purpose: package-boundary behavior in a workspace with shared code. +- File map: `case9/src/**`, `case9/tests/**`, plus `case9-shared/src/index.ts`. +- Run command: `cd cases/case9 && bun test`. +- What it proves: a case package can consume workspace-local shared code without hiding the deployable Worker boundary. +- Docs links: `/docs/monorepo-turborepo`, `/docs/project-architecture`. +- Support status: full local example. + +### Case 10: Path Aliases + +- Purpose: TypeScript path alias behavior in Worker builds and tests. +- File map: `src/lib/**`, `src/types/**`, `src/utils/**`, `tests/path-aliases.test.ts`. +- Run command: `cd cases/case10 && bun test`. +- What it proves: aliases stay visible to the Worker build and the Bun test lane. +- Docs links: `/docs/project-architecture`, `/docs/testing-overview`. +- Support status: full local example. + +### Case 11: Cross-Package DO + +- Purpose: a Worker package binds to a Durable Object class from another package. +- File map: `case11/src/fetch.ts`, `case11/tests/cross-package-do.test.ts`, `case11-do-shared/src/do.session.ts`. +- Run command: `cd cases/case11 && bun test`. +- What it proves: cross-package Durable Object references can be resolved locally. +- Docs links: `/docs/bindings/durable-objects`, `/docs/multi-workers`. +- Support status: full local example. + +### Case 12: Email Handlers + +- Purpose: inbound email handler testing through the public helper surface. +- File map: `src/email.ts`, `tests/email.test.ts`. +- Run command: `cd cases/case12 && bun test`. +- What it proves: `createTestContext()` can discover the email handler and `cf.email.send()` can invoke it in tests. +- Docs links: `/docs/bindings/send-email`, `/docs/create-test-context`. +- Support status: full helper coverage with Email Routing ingress-fidelity caveat. + +### Case 13: Tail Workers + +- Purpose: Tail Worker handler testing. +- File map: `src/tail.ts`, `tests/tail.test.ts`. +- Run command: `cd cases/case13 && bun test`. +- What it proves: `cf.tail.trigger()` can invoke a local tail handler and wait for `waitUntil()` work. +- Docs links: `/docs/create-test-context`, `/docs/test-helper-reference`. +- Support status: full helper coverage; live Tail Worker routing is Cloudflare-owned. + +### Case 14: Hyperdrive + +- Purpose: Hyperdrive binding shape, local connection-string wiring, and conservative local smoke coverage. +- File map: `src/fetch.ts`, `tests/hyperdrive.test.ts`. +- Run command: `cd cases/case14 && bun test`. +- What it proves: the binding is wired, exposes expected connection metadata, and can run against an explicit local database connection string. +- Docs links: `/docs/bindings/hyperdrive`, `/docs/feature-index`. +- Support status: full local when a binding has a local database connection string; hosted pooling, placement, credentials, and production routing stay Cloudflare-owned. + +### Case 15: Vectorize & AI + +- Purpose: remote-boundary testing for AI and Vectorize. +- File map: `src/fetch.ts`, `tests/ai-vectorize.test.ts`. +- Run command: `cd cases/case15 && bun test`. +- What it proves: remote-gated tests can make missing Cloudflare prerequisites explicit instead of failing opaquely. +- Docs links: `/docs/bindings/ai`, `/docs/bindings/vectorize`, `/docs/test-helper-reference`. +- Support status: remote-gated; requires Cloudflare auth and remote-mode prerequisites. + +### Case 16: Workflows + +- Purpose: Workflow classes, workflow binding config, and transport behavior. +- File map: `src/wf.data-pipeline.ts`, `src/wf.order-processor.ts`, `src/models.ts`, `src/transport.ts`, `tests/workflow.test.ts`. +- Run command: `cd cases/case16 && bun test`. +- What it proves: Workflow-shaped local examples can exercise class shape and transport logic. +- Docs links: `/docs/bindings/workflows`, `/docs/transport-file`. +- Support status: full local workflow class and trigger coverage; deployed durability, retries, scheduling, and instance history stay Cloudflare-owned. + +### Case 17: Plugin Namespace Example + +- Purpose: regression coverage for Rolldown plugin namespace behavior. +- File map: `src/fetch.ts`, `tests/rolldown-plugin.test.ts`. +- Run command: `cd cases/case17 && bun test`. +- What it proves: plugin namespace handling keeps working in the Worker build path. +- Docs links: `/docs/project-architecture`. +- Support status: internal regression case. + +### Case 18: SvelteKit DO + +- Purpose: SvelteKit with Devflare platform glue and a Durable Object binding. +- File map: `svelte.config.js`, `vite.config.ts`, `src/routes/**`, `src/lib/server/**`, `src/do/counter.ts`, `src/hooks.server.ts`, `tests/**`. +- Run command: `cd cases/case18 && bun test`. +- What it proves: framework output can use Devflare-managed local platform bindings. +- Docs links: `/docs/sveltekit-with-devflare`, `/docs/bindings/durable-objects`. +- Support status: full local example. + +### Case 19: Transport & DO RPC + +- Purpose: custom class round trips through Durable Object RPC-style local calls. +- File map: `src/do.counter.ts`, `src/DoubleableNumber.ts`, `src/transport.ts`, `tests/counter.test.ts`. +- Run command: `cd cases/case19 && bun test`. +- What it proves: `src/transport.ts` can preserve custom classes across local DO method calls. +- Docs links: `/docs/transport-file`, `/docs/bindings/durable-objects`. +- Support status: full local example. diff --git a/cases/case1/devflare.config.ts b/cases/case1/devflare.config.ts new file mode 100644 index 0000000..bc4e079 --- /dev/null +++ b/cases/case1/devflare.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case1-basic-worker', + compatibilityDate: '2026-04-26', + + bindings: { + kv: { + CACHE: 'cache-kv-id' + } + }, + + vars: { + LOG_LEVEL: 'info' + } +}) diff --git a/cases/case1/env.d.ts b/cases/case1/env.d.ts new file mode 100644 index 0000000..d0c5877 --- /dev/null +++ b/cases/case1/env.d.ts @@ -0,0 +1,20 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace } from '@cloudflare/workers-types' + +import type { InferConfigVars } from 'devflare/config' +type __DevflareConfigVars = InferConfigVars> + +declare global { + interface DevflareVars extends __DevflareConfigVars {} + interface DevflareEnv extends __DevflareConfigVars { + CACHE: KVNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case1/package.json b/cases/case1/package.json new file mode 100644 index 0000000..cdb177e --- /dev/null +++ b/cases/case1/package.json @@ -0,0 +1,20 @@ +{ + "name": "@devflare/case1-basic-worker", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build", + "deploy": "devflare deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case1/src/fetch.ts b/cases/case1/src/fetch.ts new file mode 100644 index 0000000..06f0077 --- /dev/null +++ b/cases/case1/src/fetch.ts @@ -0,0 +1,59 @@ +// ============================================================================= +// Case 1: Basic Worker - Fetch Handler +// ============================================================================= +// Demonstrates devflare's unified env access pattern: +// - import { env } from 'devflare' works anywhere +// - GlobalDevflareEnv provides type safety +// ============================================================================= + +import { env } from 'devflare' + +/** + * Fetch handler demonstrating unified env access + */ +export default async function fetch( + request: Request, + _rawEnv: DevflareEnv, + _ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // GET / - Welcome + if (url.pathname === '/') { + return new Response('Hello from Case 1: Basic Worker!', { + headers: { 'Content-Type': 'text/plain' } + }) + } + + // GET /env - Show vars + if (url.pathname === '/env') { + return Response.json({ LOG_LEVEL: env.LOG_LEVEL }) + } + + // /cache/:key - KV operations using unified env + if (url.pathname.startsWith('/cache/')) { + const key = url.pathname.slice(7) + + if (request.method === 'GET') { + const value = await env.CACHE.get(key) + return value + ? new Response(value) + : new Response('Not found', { status: 404 }) + } + + if (request.method === 'PUT') { + const value = await request.text() + await env.CACHE.put(key, value) + return new Response('Stored', { status: 201 }) + } + + if (request.method === 'DELETE') { + await env.CACHE.delete(key) + return new Response('Deleted') + } + + return new Response('Method not allowed', { status: 405 }) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case1/src/lib/users.ts b/cases/case1/src/lib/users.ts new file mode 100644 index 0000000..7da9fcc --- /dev/null +++ b/cases/case1/src/lib/users.ts @@ -0,0 +1,26 @@ +// ============================================================================= +// Business Logic: Users +// ============================================================================= +// Shared utilities that use the env proxy +// ============================================================================= + +import { env } from 'devflare' + +/** + * Get user from cache by ID + * No need to pass env as parameter โ€” it's globally available via ASL + */ +export async function getUser(id: string) { + const cached = await env.CACHE.get(`user:${id}`, 'json') + return cached as { id: string; name: string } | null +} + +/** + * Store user in cache + */ +export async function storeUser(id: string, data: { name: string }) { + await env.CACHE.put(`user:${id}`, JSON.stringify({ id, ...data }), { + expirationTtl: 300 // 5 minutes + }) + return { id, ...data } +} diff --git a/cases/case1/src/routes/cache/[key].ts b/cases/case1/src/routes/cache/[key].ts new file mode 100644 index 0000000..18940f3 --- /dev/null +++ b/cases/case1/src/routes/cache/[key].ts @@ -0,0 +1,36 @@ +// ============================================================================= +// Route: /cache/:key +// ============================================================================= + +import type { FetchEvent } from 'devflare/runtime' +import { env } from 'devflare' + +type CacheRouteEvent = FetchEvent + +/** + * GET /cache/:key - Retrieves cached value via unified env + */ +export async function GET({ params }: CacheRouteEvent): Promise { + const value = await env.CACHE.get(params.key) + if (!value) { + return new Response('Not found', { status: 404 }) + } + return new Response(value) +} + +/** + * PUT /cache/:key - Stores value in cache via unified env + */ +export async function PUT({ request, params }: CacheRouteEvent): Promise { + const value = await request.text() + await env.CACHE.put(params.key, value) + return new Response('Stored', { status: 201 }) +} + +/** + * DELETE /cache/:key - Removes value from cache via unified env + */ +export async function DELETE({ params }: CacheRouteEvent): Promise { + await env.CACHE.delete(params.key) + return new Response('Deleted') +} diff --git a/cases/case1/src/routes/env.ts b/cases/case1/src/routes/env.ts new file mode 100644 index 0000000..d67e164 --- /dev/null +++ b/cases/case1/src/routes/env.ts @@ -0,0 +1,15 @@ +// ============================================================================= +// Route: GET /env +// ============================================================================= + +import type { FetchEvent } from 'devflare/runtime' +import { env } from 'devflare' + +/** + * GET /env - Returns environment variables via unified env + */ +export async function GET(_event: FetchEvent): Promise { + return Response.json({ + LOG_LEVEL: env.LOG_LEVEL + }) +} diff --git a/cases/case1/src/routes/index.ts b/cases/case1/src/routes/index.ts new file mode 100644 index 0000000..91ad5d4 --- /dev/null +++ b/cases/case1/src/routes/index.ts @@ -0,0 +1,14 @@ +// ============================================================================= +// Route: GET / +// ============================================================================= + +import type { FetchEvent } from 'devflare/runtime' + +/** + * GET / - Returns welcome message + */ +export async function GET(_event: FetchEvent): Promise { + return new Response('Hello from Case 1: Basic Worker!', { + headers: { 'Content-Type': 'text/plain' } + }) +} diff --git a/cases/case1/tests/worker.test.ts b/cases/case1/tests/worker.test.ts new file mode 100644 index 0000000..04cd11f --- /dev/null +++ b/cases/case1/tests/worker.test.ts @@ -0,0 +1,184 @@ +// ============================================================================= +// Case 1: Basic Worker - Tests with Real Miniflare +// ============================================================================= +// Tests demonstrating devflare's unified env pattern with REAL bindings. +// No mocks โ€” uses actual Miniflare KV via createTestContext. +// +// Pattern: +// - createTestContext() in beforeAll sets up Miniflare from config +// - env.dispose() in afterAll cleans up +// - Access bindings via `import { env } from 'devflare'` +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { env } from 'devflare' +import { createFetchEvent, runWithEventContext, type FetchEvent } from 'devflare/runtime' +import { createTestContext } from 'devflare/test' + +// Import fetch handler to test +import fetchHandler from '../src/fetch' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +// Execution context mock (only what's needed for the handler) +const ctx: ExecutionContext = { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} +} + +async function invokeRoute = Record>( + handler: (event: FetchEvent) => Promise, + request: Request, + params?: TParams +): Promise { + const eventEnv = { + CACHE: env.CACHE, + LOG_LEVEL: env.LOG_LEVEL + } as DevflareEnv + + const event = createFetchEvent(request, eventEnv, ctx, { + params: (params ?? {}) as TParams + }) + + return runWithEventContext(event, () => handler(event)) +} + +// ----------------------------------------------------------------------------- +// Fetch Handler Tests +// ----------------------------------------------------------------------------- + +describe('Case 1: Basic Worker with Real KV', () => { + describe('fetch handler', () => { + test('GET / returns welcome message', async () => { + const request = new Request('http://localhost/') + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello from Case 1: Basic Worker!') + }) + + test('GET /env returns environment variables', async () => { + const request = new Request('http://localhost/env') + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(200) + const data = await response.json() as { LOG_LEVEL: string } + expect(data.LOG_LEVEL).toBe('info') + }) + + test('PUT /cache/:key stores value in REAL KV', async () => { + const request = new Request('http://localhost/cache/test-key-1', { + method: 'PUT', + body: 'test-value-1' + }) + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(201) + + // Verify directly in real KV + const stored = await env.CACHE.get('test-key-1') + expect(stored).toBe('test-value-1') + }) + + test('GET /cache/:key retrieves value from REAL KV', async () => { + // Pre-populate real KV + await env.CACHE.put('my-key', 'my-value') + + const request = new Request('http://localhost/cache/my-key') + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('my-value') + }) + + test('GET /cache/:key returns 404 for missing key', async () => { + const request = new Request('http://localhost/cache/definitely-missing') + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(404) + }) + + test('DELETE /cache/:key removes value from REAL KV', async () => { + // Pre-populate + await env.CACHE.put('delete-me-key', 'value') + + const request = new Request('http://localhost/cache/delete-me-key', { method: 'DELETE' }) + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(200) + + // Verify deleted from real KV + const stored = await env.CACHE.get('delete-me-key') + expect(stored).toBeNull() + }) + + test('unknown route returns 404', async () => { + const request = new Request('http://localhost/unknown-route') + const response = await fetchHandler(request, env as DevflareEnv, ctx) + + expect(response.status).toBe(404) + }) + }) + + describe('route handlers (file-based)', () => { + test('GET / route handler', async () => { + const { GET } = await import('../src/routes/index') + + const request = new Request('http://localhost/') + const response = await invokeRoute(GET, request) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello from Case 1: Basic Worker!') + }) + + test('GET /env route handler', async () => { + const { GET } = await import('../src/routes/env') + + const request = new Request('http://localhost/env') + const response = await invokeRoute(GET, request) + + expect(response.status).toBe(200) + const data = await response.json() as { LOG_LEVEL: string } + expect(data.LOG_LEVEL).toBe('info') + }) + + test('cache route handlers with params using REAL KV', async () => { + const { GET, PUT, DELETE } = await import('../src/routes/cache/[key]') + + // PUT + const putReq = new Request('http://localhost/cache/route-test', { method: 'PUT', body: 'route-value' }) + const putRes = await invokeRoute(PUT, putReq, { key: 'route-test' }) + expect(putRes.status).toBe(201) + + // Verify in real KV + const stored = await env.CACHE.get('route-test') + expect(stored).toBe('route-value') + + // GET + const getReq = new Request('http://localhost/cache/route-test') + const getRes = await invokeRoute(GET, getReq, { key: 'route-test' }) + expect(getRes.status).toBe(200) + expect(await getRes.text()).toBe('route-value') + + // DELETE + const delReq = new Request('http://localhost/cache/route-test', { method: 'DELETE' }) + const delRes = await invokeRoute(DELETE, delReq, { key: 'route-test' }) + expect(delRes.status).toBe(200) + + // Verify deleted + const deleted = await env.CACHE.get('route-test') + expect(deleted).toBeNull() + }) + }) +}) diff --git a/cases/case1/tsconfig.json b/cases/case1/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case1/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case10/devflare.config.ts b/cases/case10/devflare.config.ts new file mode 100644 index 0000000..04019d4 --- /dev/null +++ b/cases/case10/devflare.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case10-path-aliases', + compatibilityDate: '2026-04-26' +}) diff --git a/cases/case10/env.d.ts b/cases/case10/env.d.ts new file mode 100644 index 0000000..5c9c90d --- /dev/null +++ b/cases/case10/env.d.ts @@ -0,0 +1,13 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +declare global { + interface DevflareEnv { + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case10/package.json b/cases/case10/package.json new file mode 100644 index 0000000..c931f8d --- /dev/null +++ b/cases/case10/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devflare/case10-path-aliases", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case10/src/fetch.ts b/cases/case10/src/fetch.ts new file mode 100644 index 0000000..e732c9f --- /dev/null +++ b/cases/case10/src/fetch.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// Case 10: Path Aliases - Fetch Handler +// ============================================================================= +// Demonstrates a case with TypeScript path alias configuration. +// Runtime imports stay relative so Bun tests can execute this example reliably. +// ============================================================================= + +import { generateId, timestamp, slugify } from './utils/index' +import { createUser, successResponse, errorResponse } from './lib/index' + +/** + * Main fetch handler + * Demonstrates the case10 routing and utility flow + */ +export default async function fetch( + request: Request, + env: unknown, + ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json(successResponse({ + name: 'Case 10: Path Aliases', + message: 'Demonstrates TypeScript path aliases' + })) + } + + // Route: POST /users + if (url.pathname === '/users' && request.method === 'POST') { + try { + const body = await request.json() as { name: string, email: string } + + if (!body.name || !body.email) { + return Response.json( + errorResponse('Name and email required', 'INVALID_INPUT'), + { status: 400 } + ) + } + + const user = createUser(body.name, body.email) + return Response.json(successResponse(user), { status: 201 }) + } catch { + return Response.json( + errorResponse('Invalid JSON', 'PARSE_ERROR'), + { status: 400 } + ) + } + } + + // Route: GET /utils/demo + if (url.pathname === '/utils/demo') { + return Response.json(successResponse({ + id: generateId(), + timestamp: timestamp(), + slugified: slugify('Hello World Test') + })) + } + + return Response.json( + errorResponse('Not found', 'NOT_FOUND'), + { status: 404 } + ) +} diff --git a/cases/case10/src/lib/index.ts b/cases/case10/src/lib/index.ts new file mode 100644 index 0000000..cde81e5 --- /dev/null +++ b/cases/case10/src/lib/index.ts @@ -0,0 +1,23 @@ +// ============================================================================= +// Case 10: Path Aliases - Lib +// ============================================================================= + +import type { User, ApiResponse, ErrorResponse } from '../types/index' +import { generateId, timestamp } from '../utils/index' + +export function createUser(name: string, email: string): User { + return { + id: generateId(), + name, + email, + createdAt: timestamp() + } +} + +export function successResponse(data: T): ApiResponse { + return { success: true, data } +} + +export function errorResponse(error: string, code: string): ErrorResponse { + return { success: false, error, code } +} diff --git a/cases/case10/src/types/index.ts b/cases/case10/src/types/index.ts new file mode 100644 index 0000000..1359cbf --- /dev/null +++ b/cases/case10/src/types/index.ts @@ -0,0 +1,21 @@ +// ============================================================================= +// Case 10: Path Aliases - Types +// ============================================================================= + +export interface User { + id: string + name: string + email: string + createdAt: number +} + +export interface ApiResponse { + success: boolean + data: T +} + +export interface ErrorResponse { + success: false + error: string + code: string +} diff --git a/cases/case10/src/utils/index.ts b/cases/case10/src/utils/index.ts new file mode 100644 index 0000000..a4203c7 --- /dev/null +++ b/cases/case10/src/utils/index.ts @@ -0,0 +1,18 @@ +// ============================================================================= +// Case 10: Path Aliases - Utils +// ============================================================================= + +export function generateId(): string { + return crypto.randomUUID() +} + +export function timestamp(): number { + return Date.now() +} + +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') +} diff --git a/cases/case10/tests/path-aliases.test.ts b/cases/case10/tests/path-aliases.test.ts new file mode 100644 index 0000000..7cdfa4b --- /dev/null +++ b/cases/case10/tests/path-aliases.test.ts @@ -0,0 +1,111 @@ +// ============================================================================= +// Case 10: Path Aliases - Tests +// ============================================================================= +// Tests for the case10 utilities. +// These imports are relative because Bun does not resolve package-local TS aliases here. +// ============================================================================= + +import { describe, expect, it } from 'bun:test' + +import { generateId, timestamp, slugify } from '../src/utils/index' +import { createUser, successResponse, errorResponse } from '../src/lib/index' +import type { User, ApiResponse, ErrorResponse } from '../src/types/index' + +describe('Case 10: Path Aliases', () => { + describe('@utils', () => { + describe('generateId', () => { + it('generates UUID format', () => { + const id = generateId() + + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + }) + + it('generates unique IDs', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateId())) + expect(ids.size).toBe(100) + }) + }) + + describe('timestamp', () => { + it('returns current time in milliseconds', () => { + const before = Date.now() + const ts = timestamp() + const after = Date.now() + + expect(ts).toBeGreaterThanOrEqual(before) + expect(ts).toBeLessThanOrEqual(after) + }) + }) + + describe('slugify', () => { + it('converts to lowercase', () => { + expect(slugify('HELLO')).toBe('hello') + }) + + it('replaces spaces with dashes', () => { + expect(slugify('hello world')).toBe('hello-world') + }) + + it('removes special characters', () => { + expect(slugify('hello! @world#')).toBe('hello-world') + }) + + it('handles complex strings', () => { + expect(slugify('My Blog Post Title!')).toBe('my-blog-post-title') + }) + }) + }) + + describe('@lib', () => { + describe('createUser', () => { + it('creates user with all fields', () => { + const user = createUser('John Doe', 'john@example.com') + + expect(user.name).toBe('John Doe') + expect(user.email).toBe('john@example.com') + expect(user.id).toBeDefined() + expect(user.createdAt).toBeTypeOf('number') + }) + }) + + describe('successResponse', () => { + it('wraps data in success envelope', () => { + const response = successResponse({ foo: 'bar' }) + + expect(response.success).toBe(true) + expect(response.data).toEqual({ foo: 'bar' }) + }) + }) + + describe('errorResponse', () => { + it('creates error envelope', () => { + const response = errorResponse('Something failed', 'ERR_FAIL') + + expect(response.success).toBe(false) + expect(response.error).toBe('Something failed') + expect(response.code).toBe('ERR_FAIL') + }) + }) + }) + + describe('@types', () => { + it('types are usable', () => { + const user: User = { + id: '123', + name: 'Test', + email: 'test@example.com', + createdAt: Date.now() + } + + const response: ApiResponse = { + success: true, + data: user + } + + expect(response.success).toBe(true) + expect(response.data.id).toBe('123') + }) + }) +}) diff --git a/cases/case10/tsconfig.json b/cases/case10/tsconfig.json new file mode 100644 index 0000000..f3b6053 --- /dev/null +++ b/cases/case10/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/*": [ + "./src/*" + ], + "@lib/*": [ + "./src/lib/*" + ], + "@utils/*": [ + "./src/utils/*" + ], + "#types/*": [ + "./src/types/*" + ] + } + }, + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case11-do-shared/devflare.config.ts b/cases/case11-do-shared/devflare.config.ts new file mode 100644 index 0000000..cf35a45 --- /dev/null +++ b/cases/case11-do-shared/devflare.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case11-do-shared', + compatibilityDate: '2026-04-26', + + bindings: { + // Local bindings for the DOs hosted in this worker + durableObjects: { + SESSION_STORE: 'SessionStore' + } + } +}) diff --git a/cases/case11-do-shared/env.d.ts b/cases/case11-do-shared/env.d.ts new file mode 100644 index 0000000..08a8419 --- /dev/null +++ b/cases/case11-do-shared/env.d.ts @@ -0,0 +1,18 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + SESSION_STORE: DurableObjectNamespace + } +} + +declare module 'devflare/test' { + interface DevflareEnv { + SESSION_STORE: DurableObjectNamespace + } +} + +export {} diff --git a/cases/case11-do-shared/package.json b/cases/case11-do-shared/package.json new file mode 100644 index 0000000..0334839 --- /dev/null +++ b/cases/case11-do-shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@devflare/case11-do-shared", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./devflare.config": "./devflare.config.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case11-do-shared/src/do.session.ts b/cases/case11-do-shared/src/do.session.ts new file mode 100644 index 0000000..0beee59 --- /dev/null +++ b/cases/case11-do-shared/src/do.session.ts @@ -0,0 +1,129 @@ +// ============================================================================= +// Case 11: Cross-Package DO - Session Store +// ============================================================================= +// Shared Durable Object that can be referenced by other workers. +// Follows devflare patterns: +// - Extends DurableObject from cloudflare:workers +// - Uses RPC methods (not fetch) for direct method invocation +// - File named do.*.ts for auto-discovery +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' + +/** + * JSON-serializable primitive types for RPC compatibility. + */ +export type JsonPrimitive = string | number | boolean | null + +/** + * JSON-serializable value (limited depth for RPC compatibility). + * Deep nesting is not supported due to Rpc.Serializable constraints. + * For simple key-value storage, see case3's flat SessionValue pattern. + */ +export type JsonValue = JsonPrimitive | JsonPrimitive[] | { [key: string]: JsonPrimitive } + +/** + * Session data structure. + * Uses JsonValue for data to ensure RPC serialization compatibility. + * Supports one level of nesting (e.g., `{ role: 'admin', tags: ['a', 'b'] }`). + */ +export interface SessionData { + id: string + data: { [key: string]: JsonValue } + createdAt: number + expiresAt?: number +} + +/** + * Shared Session Store Durable Object + * Can be referenced by any worker via ref() pattern. + * + * Extends DurableObject from cloudflare:workers for Miniflare RPC compatibility. + */ +export class SessionStore extends DurableObject { + private sessions: Map = new Map() + + // ------------------------------------------------------------------------- + // RPC Methods โ€” Direct method calls via env.SESSION_STORE.get(id).method() + // ------------------------------------------------------------------------- + + /** + * RPC method: Get session by ID + */ + async getSession(sessionId: string): Promise { + // Try memory cache first + let session = this.sessions.get(sessionId) + + // Fall back to storage + if (!session) { + session = await this.ctx.storage.get(`session:${sessionId}`) + if (session) { + this.sessions.set(sessionId, session) + } + } + + if (!session) { + return null + } + + // Check expiry + if (session.expiresAt && session.expiresAt < Date.now()) { + await this.deleteSession(sessionId) + return null + } + + return session + } + + /** + * RPC method: Create or update session + */ + async setSession(sessionId: string, data: { [key: string]: JsonValue }, expiresAt?: number): Promise { + const session: SessionData = { + id: sessionId, + data, + createdAt: Date.now(), + expiresAt: expiresAt ?? Date.now() + 24 * 60 * 60 * 1000 // 24h default + } + + this.sessions.set(sessionId, session) + await this.ctx.storage.put(`session:${sessionId}`, session) + + return session + } + + /** + * RPC method: Delete session + */ + async deleteSession(sessionId: string): Promise { + const existed = this.sessions.has(sessionId) || + (await this.ctx.storage.get(`session:${sessionId}`)) !== undefined + + this.sessions.delete(sessionId) + await this.ctx.storage.delete(`session:${sessionId}`) + + return existed + } + + /** + * RPC method: Check if session exists and is valid + */ + async hasSession(sessionId: string): Promise { + const session = await this.getSession(sessionId) + return session !== null + } + + /** + * RPC method: Extend session expiry + */ + async extendSession(sessionId: string, additionalMs: number): Promise { + const session = await this.getSession(sessionId) + if (!session) return null + + session.expiresAt = (session.expiresAt ?? Date.now()) + additionalMs + this.sessions.set(sessionId, session) + await this.ctx.storage.put(`session:${sessionId}`, session) + + return session + } +} diff --git a/cases/case11-do-shared/src/index.ts b/cases/case11-do-shared/src/index.ts new file mode 100644 index 0000000..a70f1e3 --- /dev/null +++ b/cases/case11-do-shared/src/index.ts @@ -0,0 +1,8 @@ +// ============================================================================= +// Case 11: Cross-Package DO - Type Exports +// ============================================================================= +// Re-exports types from the DO file for consumer convenience. +// This file is the package entrypoint (see package.json exports). +// ============================================================================= + +export { SessionStore, type SessionData, type JsonPrimitive, type JsonValue } from './do.session' diff --git a/cases/case11-do-shared/tsconfig.json b/cases/case11-do-shared/tsconfig.json new file mode 100644 index 0000000..af2a0e2 --- /dev/null +++ b/cases/case11-do-shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*" + ] +} diff --git a/cases/case11/devflare.config.ts b/cases/case11/devflare.config.ts new file mode 100644 index 0000000..1974987 --- /dev/null +++ b/cases/case11/devflare.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, ref } from 'devflare/config' + +// ============================================================================= +// Case 11: Cross-Package Durable Objects (Monorepo Pattern) +// ============================================================================= +// Demonstrates using a Durable Object from a shared workspace package via ref(). +// +// Monorepo Pattern: +// 1. Add workspace dependency: "@devflare/case11-do-shared": "workspace:*" +// 2. Shared package exports its devflare.config: "./devflare.config": "./devflare.config.ts" +// 3. Use ref() with package import (NOT relative path): +// const doShared = ref(() => import('@devflare/case11-do-shared/devflare.config')) +// 4. Use doShared.BINDING_NAME to get cross-package DO bindings +// 5. Access the DO: env.SESSION_STORE.get(id).getSession('user123') +// +// The test context automatically sets up multi-worker Miniflare when it +// detects cross-worker DO bindings (those with __ref). +// +// NOTE: This is the recommended pattern for monorepos. Relative paths like +// '../case11-do-shared/devflare.config' work but don't demonstrate the +// full monorepo workflow with proper package boundaries. +// ============================================================================= + +const doShared = ref(() => import('@devflare/case11-do-shared/devflare.config')) + +export default defineConfig({ + name: 'case11-cross-package-do', + compatibilityDate: '2026-04-26', + + bindings: { + durableObjects: { + // Cross-package DO โ€” hosted by case11-do-shared + // doShared.SESSION_STORE returns { className: 'SessionStore', scriptName: 'case11-do-shared', __ref } + SESSION_STORE: doShared.SESSION_STORE + } + } +}) diff --git a/cases/case11/env.d.ts b/cases/case11/env.d.ts new file mode 100644 index 0000000..872efee --- /dev/null +++ b/cases/case11/env.d.ts @@ -0,0 +1,16 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + SESSION_STORE: DurableObjectNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case11/package.json b/cases/case11/package.json new file mode 100644 index 0000000..1125651 --- /dev/null +++ b/cases/case11/package.json @@ -0,0 +1,21 @@ +{ + "name": "@devflare/case11-cross-package-do", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build" + }, + "dependencies": { + "@devflare/case11-do-shared": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case11/src/fetch.ts b/cases/case11/src/fetch.ts new file mode 100644 index 0000000..554028e --- /dev/null +++ b/cases/case11/src/fetch.ts @@ -0,0 +1,60 @@ +// ============================================================================= +// Case 11: Cross-Package DO - Fetch Handler (Monorepo Pattern) +// ============================================================================= +// Demonstrates using a Durable Object from a workspace package via RPC. +// Uses devflare pattern: export function fetch() + import { env } +// +// In a monorepo, import types from the package name, not relative paths: +// import type { SessionData } from '@devflare/case11-do-shared' +// ============================================================================= + +import { env } from 'devflare' +import type { SessionData, JsonValue } from '@devflare/case11-do-shared' + +/** + * Main fetch handler + * Demonstrates using DO classes from shared packages via RPC + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json({ + name: 'Case 11: Cross-Package Durable Objects', + message: 'Demonstrates using DO classes from shared packages via ref()' + }) + } + + // Route: GET/POST/DELETE /session/:userId + const sessionMatch = url.pathname.match(/^\/session\/([^/]+)$/) + if (sessionMatch) { + const userId = sessionMatch[1] + const id = env.SESSION_STORE.idFromName(userId) + const stub = env.SESSION_STORE.get(id) + + if (request.method === 'GET') { + // Get session via RPC + const session = await stub.getSession(userId) + if (!session) { + return Response.json({ error: 'Session not found' }, { status: 404 }) + } + return Response.json(session) + } + + if (request.method === 'POST') { + // Create/update session via RPC + const body = await request.json() as { data?: { [key: string]: JsonValue }; expiresAt?: number } + const session = await stub.setSession(userId, body.data ?? {}, body.expiresAt) + return Response.json(session, { status: 201 }) + } + + if (request.method === 'DELETE') { + // Delete session via RPC + await stub.deleteSession(userId) + return new Response(null, { status: 204 }) + } + } + + return Response.json({ error: 'Not found' }, { status: 404 }) +} diff --git a/cases/case11/tests/cross-package-do.test.ts b/cases/case11/tests/cross-package-do.test.ts new file mode 100644 index 0000000..3a113c5 --- /dev/null +++ b/cases/case11/tests/cross-package-do.test.ts @@ -0,0 +1,136 @@ +// ============================================================================= +// Case 11: Cross-Package DO - Tests (Monorepo Pattern) +// ============================================================================= +// Tests for the cross-package Durable Object pattern using real Miniflare. +// Uses createTestContext() which auto-detects cross-worker DO bindings. +// +// In a monorepo, import types from the package name, not relative paths: +// import type { SessionData } from '@devflare/case11-do-shared' +// ============================================================================= + +import { describe, expect, test, beforeAll, afterAll } from 'bun:test' +import { createTestContext } from 'devflare/test' +import { env } from 'devflare' +import type { SessionData } from '@devflare/case11-do-shared' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +// ----------------------------------------------------------------------------- +// Session Store DO Tests via RPC +// ----------------------------------------------------------------------------- + +describe('Case 11: Cross-Package Durable Objects', () => { + describe('SessionStore DO via RPC', () => { + test('can get DO stub via SESSION_STORE binding', async () => { + const id = env.SESSION_STORE.idFromName('test-session') + const stub = env.SESSION_STORE.get(id) + expect(stub).toBeDefined() + }) + + test('setSession creates new session', async () => { + const id = env.SESSION_STORE.idFromName('user-create') + const stub = env.SESSION_STORE.get(id) + + const session = await stub.setSession('user-create', { role: 'admin' }) + + expect(session.id).toBe('user-create') + expect(session.data).toEqual({ role: 'admin' }) + expect(session.createdAt).toBeTypeOf('number') + expect(session.expiresAt).toBeGreaterThan(Date.now()) + }) + + test('getSession retrieves existing session', async () => { + const id = env.SESSION_STORE.idFromName('user-get') + const stub = env.SESSION_STORE.get(id) + + // Create session first + await stub.setSession('user-get', { foo: 'bar' }) + + // Get session + const session = await stub.getSession('user-get') + + expect(session).not.toBeNull() + expect(session!.data).toEqual({ foo: 'bar' }) + }) + + test('getSession returns null for missing session', async () => { + const id = env.SESSION_STORE.idFromName('nonexistent') + const stub = env.SESSION_STORE.get(id) + + const session = await stub.getSession('nonexistent') + + expect(session).toBeNull() + }) + + test('deleteSession removes session', async () => { + const id = env.SESSION_STORE.idFromName('user-delete') + const stub = env.SESSION_STORE.get(id) + + // Create session + await stub.setSession('user-delete', { temp: true }) + + // Delete it + const deleted = await stub.deleteSession('user-delete') + expect(deleted).toBe(true) + + // Verify deleted + const session = await stub.getSession('user-delete') + expect(session).toBeNull() + }) + + test('hasSession returns true for existing session', async () => { + const id = env.SESSION_STORE.idFromName('user-has') + const stub = env.SESSION_STORE.get(id) + + await stub.setSession('user-has', {}) + + const exists = await stub.hasSession('user-has') + expect(exists).toBe(true) + }) + + test('hasSession returns false for missing session', async () => { + const id = env.SESSION_STORE.idFromName('user-missing') + const stub = env.SESSION_STORE.get(id) + + const exists = await stub.hasSession('user-missing') + expect(exists).toBe(false) + }) + + test('extendSession extends expiry time', async () => { + const id = env.SESSION_STORE.idFromName('user-extend') + const stub = env.SESSION_STORE.get(id) + + // Create session with short expiry + const original = await stub.setSession('user-extend', {}, Date.now() + 1000) + const originalExpiry = original.expiresAt! + + // Extend by 1 hour + const extended = await stub.extendSession('user-extend', 60 * 60 * 1000) + + expect(extended).not.toBeNull() + expect(extended!.expiresAt).toBeGreaterThan(originalExpiry) + }) + + test('getSession returns null for expired session', async () => { + const id = env.SESSION_STORE.idFromName('user-expired') + const stub = env.SESSION_STORE.get(id) + + // Create session that's already expired + await stub.setSession('user-expired', {}, Date.now() - 1000) + + // Should return null (expired) + const session = await stub.getSession('user-expired') + expect(session).toBeNull() + }) + }) +}) diff --git a/cases/case11/tsconfig.json b/cases/case11/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case11/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case12/devflare.config.ts b/cases/case12/devflare.config.ts new file mode 100644 index 0000000..44d3cb0 --- /dev/null +++ b/cases/case12/devflare.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case12-email-handlers', + compatibilityDate: '2026-04-26', + + bindings: { + // KV for storing processed emails + kv: { + EMAIL_LOG: 'email-log-kv-id' + }, + + // Send email binding example for outgoing emails. + // Devflare models this through config compilation, env types, and local runtime flows. + sendEmail: { + EMAIL: {} + } + }, + + vars: { + // Forward emails to this address + FORWARD_ADDRESS: 'admin@example.com' + } +}) diff --git a/cases/case12/env.d.ts b/cases/case12/env.d.ts new file mode 100644 index 0000000..6f19d1e --- /dev/null +++ b/cases/case12/env.d.ts @@ -0,0 +1,21 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace, SendEmail } from '@cloudflare/workers-types' + +import type { InferConfigVars } from 'devflare/config' +type __DevflareConfigVars = InferConfigVars> + +declare global { + interface DevflareVars extends __DevflareConfigVars {} + interface DevflareEnv extends __DevflareConfigVars { + EMAIL_LOG: KVNamespace + EMAIL: SendEmail + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case12/package.json b/cases/case12/package.json new file mode 100644 index 0000000..8bd633d --- /dev/null +++ b/cases/case12/package.json @@ -0,0 +1,21 @@ +{ + "name": "@devflare/case12-email-handlers", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build", + "deploy": "devflare deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "mimetext": "^3.0.24", + "postal-mime": "^2.4.1", + "typescript": "^5.7.2" + } +} diff --git a/cases/case12/src/email.ts b/cases/case12/src/email.ts new file mode 100644 index 0000000..c4465f4 --- /dev/null +++ b/cases/case12/src/email.ts @@ -0,0 +1,120 @@ +// ============================================================================= +// Case 12: Email Handlers โ€” Email Handler +// ============================================================================= +// Handles incoming emails with ForwardableEmailMessage API +// Demonstrates: parsing, replying, forwarding, and logging emails +// ============================================================================= + +import * as PostalMime from 'postal-mime' +import { createMimeMessage } from 'mimetext' +import { env } from 'devflare' +import type { ForwardableEmailMessage, EmailMessage } from '@cloudflare/workers-types' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +// Parsed email structure from postal-mime +interface ParsedEmail { + headers: Array<{ key: string; value: string }> + from?: { address: string; name: string } + to?: Array<{ address: string; name: string }> + replyTo?: Array<{ address: string; name: string }> + subject?: string + messageId?: string + date?: string + html?: string + text?: string + attachments: Array<{ + filename?: string + mimeType?: string + disposition?: string + content: ArrayBuffer + }> +} + +// ----------------------------------------------------------------------------- +// Email Handler +// ----------------------------------------------------------------------------- + +/** + * Create an auto-reply message + */ +function createAutoReply( + original: ForwardableEmailMessage, + parsed: ParsedEmail, + ticketId: string +): EmailMessage { + const msg = createMimeMessage() + + msg.setSender({ + name: 'Auto-Reply System', + addr: original.to + }) + msg.setRecipient(original.from) + + // Set In-Reply-To header for threading + const originalMessageId = original.headers.get('Message-ID') + if (originalMessageId) { + msg.setHeader('In-Reply-To', originalMessageId) + } + + msg.setSubject(`Re: ${parsed.subject || 'Your message'}`) + + msg.addMessage({ + contentType: 'text/plain', + data: `Thank you for your email. + +We have received your message and created ticket #${ticketId}. + +Your original message: +Subject: ${parsed.subject || '(no subject)'} +Received: ${new Date().toISOString()} + +We will respond as soon as possible. + +--- +This is an automated response.` + }) + + // Create EmailMessage (simplified for local dev) + return { + from: original.to, + to: original.from, + raw: msg.asRaw() + } as unknown as EmailMessage +} + +/** + * Email handler - processes incoming emails + */ +export async function email(message: ForwardableEmailMessage): Promise { + // Parse the incoming email + const parser = new PostalMime.default() + const rawEmail = new Response(message.raw as unknown as ReadableStream) + const parsed = await parser.parse(await rawEmail.arrayBuffer()) as ParsedEmail + + // Log the email to KV + const emailId = crypto.randomUUID() + await env.EMAIL_LOG.put( + `email:${emailId}`, + JSON.stringify({ + id: emailId, + from: message.from, + to: message.to, + subject: parsed.subject, + receivedAt: new Date().toISOString(), + bodyPreview: (parsed.text || parsed.html || '').slice(0, 200) + }), + { expirationTtl: 86400 } // 24 hours + ) + + // Auto-reply to sender + const replyMessage = createAutoReply(message, parsed, emailId) + await message.reply(replyMessage) + + // Forward to admin + if (env.FORWARD_ADDRESS) { + await message.forward(env.FORWARD_ADDRESS) + } +} diff --git a/cases/case12/tests/email.test.ts b/cases/case12/tests/email.test.ts new file mode 100644 index 0000000..b9b630f --- /dev/null +++ b/cases/case12/tests/email.test.ts @@ -0,0 +1,173 @@ +// ============================================================================= +// Case 12: Email Handlers โ€” Tests +// ============================================================================= +// Tests for email handling using devflare/test email helper. +// These assertions cover direct handler delivery under createTestContext(), +// plus the recorded reply/forward side effects exposed by the helper. +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { createTestContext, email } from 'devflare/test' +import { env } from 'devflare' +import type { ReceivedEmail } from 'devflare/test' + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +async function listEmailLogKeys(): Promise { + const result = await env.EMAIL_LOG.list({ prefix: 'email:' }) + return result.keys.map((key) => key.name) +} + +async function getLoggedEmail(beforeKeys: string[]): Promise<{ + id: string + from: string + to: string + subject?: string + receivedAt: string + bodyPreview: string +}> { + const afterKeys = await listEmailLogKeys() + const newKey = afterKeys.find((key) => !beforeKeys.includes(key)) + expect(newKey).toBeDefined() + + const stored = await env.EMAIL_LOG.get(newKey!) + expect(stored).not.toBeNull() + + return JSON.parse(stored!) as { + id: string + from: string + to: string + subject?: string + receivedAt: string + bodyPreview: string + } +} + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +beforeEach(() => { + email.clearSentEmails() +}) + +// ----------------------------------------------------------------------------- +// Email Handler Tests +// ----------------------------------------------------------------------------- + +describe('Email Handler', () => { + test('email.send() delivers to src/email.ts and records reply/forward side effects', async () => { + const beforeKeys = await listEmailLogKeys() + + const response = await email.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Hello from devflare', + body: 'This is a test email sent via the email helper.' + }) + + expect(response.ok).toBe(true) + + const loggedEmail = await getLoggedEmail(beforeKeys) + expect(loggedEmail.from).toBe('sender@example.com') + expect(loggedEmail.to).toBe('recipient@example.com') + expect(loggedEmail.subject).toBe('Hello from devflare') + expect(loggedEmail.bodyPreview).toContain('This is a test email') + + const sentEmails = email.getSentEmails() + expect(sentEmails).toHaveLength(2) + expect(sentEmails.some((msg) => msg.type === 'reply' && msg.to === 'sender@example.com')).toBe(true) + expect(sentEmails.some((msg) => msg.type === 'forward' && msg.to === 'admin@example.com')).toBe(true) + }) + + test('email.send() accepts raw email content and still reaches the handler', async () => { + const beforeKeys = await listEmailLogKeys() + const rawEmail = [ + 'From: raw@example.com', + 'To: recipient@example.com', + 'Subject: Raw Email Test', + 'Date: ' + new Date().toUTCString(), + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'This is raw email content.' + ].join('\r\n') + + const response = await email.send({ + from: 'raw@example.com', + to: 'recipient@example.com', + raw: rawEmail + }) + + expect(response.ok).toBe(true) + + const loggedEmail = await getLoggedEmail(beforeKeys) + expect(loggedEmail.from).toBe('raw@example.com') + expect(loggedEmail.subject).toBe('Raw Email Test') + }) +}) + +// ----------------------------------------------------------------------------- +// Email Listener Tests +// ----------------------------------------------------------------------------- + +describe('Email Listeners', () => { + test('onReceive() observes outgoing reply/forward emails', async () => { + const received: ReceivedEmail[] = [] + + const unsubscribe = email.onReceive((msg) => { + received.push(msg) + }) + + await email.send({ + from: 'listener@example.com', + to: 'recipient@example.com', + subject: 'Listener test', + body: 'Trigger outgoing email observers' + }) + + unsubscribe() + + expect(received).toHaveLength(2) + expect(received.some((msg) => msg.type === 'reply')).toBe(true) + expect(received.some((msg) => msg.type === 'forward')).toBe(true) + }) + + test('should clear sent emails history', () => { + email.onReceive(() => { })() + email.clearSentEmails() + const sentEmails = email.getSentEmails() + expect(sentEmails.length).toBe(0) + }) +}) + +// ----------------------------------------------------------------------------- +// KV Integration Tests (requires proper binding configuration) +// ----------------------------------------------------------------------------- + +describe('KV Integration', () => { + test('should have EMAIL_LOG KV namespace available', () => { + expect(env.EMAIL_LOG).toBeDefined() + }) +}) + +// ----------------------------------------------------------------------------- +// Vars Tests +// ----------------------------------------------------------------------------- + +describe('Environment Variables', () => { + test('should have FORWARD_ADDRESS var available', () => { + expect(env.FORWARD_ADDRESS).toBe('admin@example.com') + }) +}) + diff --git a/cases/case12/tsconfig.json b/cases/case12/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case12/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case13/devflare.config.ts b/cases/case13/devflare.config.ts new file mode 100644 index 0000000..9db1537 --- /dev/null +++ b/cases/case13/devflare.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case13-tail-workers', + compatibilityDate: '2026-04-26', + + // This case calls src/tail.ts directly in tests while tail helper wiring + // remains a manual/advanced path. + + bindings: { + // KV for storing processed log entries + kv: { + LOG_STORE: 'log-store-kv-id' + } + }, + + vars: { + // Minimum log level to capture + MIN_LOG_LEVEL: 'log' + } +}) diff --git a/cases/case13/env.d.ts b/cases/case13/env.d.ts new file mode 100644 index 0000000..df29ceb --- /dev/null +++ b/cases/case13/env.d.ts @@ -0,0 +1,20 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace } from '@cloudflare/workers-types' + +import type { InferConfigVars } from 'devflare/config' +type __DevflareConfigVars = InferConfigVars> + +declare global { + interface DevflareVars extends __DevflareConfigVars {} + interface DevflareEnv extends __DevflareConfigVars { + LOG_STORE: KVNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case13/package.json b/cases/case13/package.json new file mode 100644 index 0000000..47b787a --- /dev/null +++ b/cases/case13/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case13-tail-workers", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case13/src/tail.ts b/cases/case13/src/tail.ts new file mode 100644 index 0000000..d52dbb6 --- /dev/null +++ b/cases/case13/src/tail.ts @@ -0,0 +1,112 @@ +// ============================================================================= +// Case 13: Tail Workers โ€” Tail Handler +// ============================================================================= +// Demonstrates processing logs from producer workers via tail() handler. +// Logs are batched and delivered after producer worker execution completes. +// ============================================================================= + +import { env } from 'devflare' +import type { TraceItem, TraceLog } from '@cloudflare/workers-types' + +/** + * Log entry structure for storage + */ +export interface LogEntry { + id: string + scriptName: string + outcome: string + eventTimestamp: number + logs: Array<{ + level: string + message: string[] + timestamp: number + }> + exceptions: Array<{ + name: string + message: string + timestamp: number + }> + request?: { + url: string + method: string + } +} + +/** + * Process trace items and extract relevant log data + */ +function processTraceItem(event: TraceItem): LogEntry { + const logs = (event.logs ?? []).map((log: TraceLog) => ({ + level: log.level, + message: log.message as string[], + timestamp: log.timestamp + })) + + const exceptions = (event.exceptions ?? []).map((ex) => ({ + name: ex.name, + message: ex.message, + timestamp: ex.timestamp + })) + + // Extract request info if available + const request = event.event && 'request' in event.event + ? { + url: (event.event.request as { url: string }).url, + method: (event.event.request as { method: string }).method + } + : undefined + + return { + id: `${event.scriptName}-${event.eventTimestamp}`, + scriptName: event.scriptName ?? 'unknown', + outcome: event.outcome, + eventTimestamp: event.eventTimestamp ?? Date.now(), + logs, + exceptions, + request + } +} + +/** + * Filter logs by minimum level + */ +function filterByLevel(entry: LogEntry, minLevel: string): LogEntry { + const levels = ['debug', 'log', 'info', 'warn', 'error'] + const minIndex = levels.indexOf(minLevel) + + if (minIndex === -1) return entry + + return { + ...entry, + logs: entry.logs.filter((log) => { + const logIndex = levels.indexOf(log.level) + return logIndex >= minIndex + }) + } +} + +/** + * Tail handler - processes logs from producer workers + */ +export async function tail(events: TraceItem[]): Promise { + const minLevel = env.MIN_LOG_LEVEL ?? 'log' + + for (const event of events) { + // Process the trace item + let entry = processTraceItem(event) + + // Filter by minimum log level + entry = filterByLevel(entry, minLevel) + + // Skip if no logs or exceptions after filtering + if (entry.logs.length === 0 && entry.exceptions.length === 0) { + continue + } + + // Store in KV for later retrieval + const key = `tail:${entry.id}` + await env.LOG_STORE.put(key, JSON.stringify(entry), { + expirationTtl: 86400 // 24 hours + }) + } +} diff --git a/cases/case13/tests/tail.test.ts b/cases/case13/tests/tail.test.ts new file mode 100644 index 0000000..582ea36 --- /dev/null +++ b/cases/case13/tests/tail.test.ts @@ -0,0 +1,282 @@ +// ============================================================================= +// Case 13: Tail Workers โ€” Tests +// ============================================================================= +// Tests the tail handler through cf.tail.trigger() while still using +// REAL Miniflare KV bindings via createTestContext. +// No mocks - these tests use actual KV operations. +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' +import type { TraceItem } from '@cloudflare/workers-types' +import type { LogEntry } from '../src/tail' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +// ----------------------------------------------------------------------------- +// Test Data Helpers +// ----------------------------------------------------------------------------- + +function createTraceItem(overrides: Partial = {}): TraceItem { + return { + scriptName: 'test-worker', + outcome: 'ok', + eventTimestamp: Date.now(), + event: { + request: { + url: 'https://example.com/api/test', + method: 'GET' + } + }, + logs: [ + { + level: 'log', + message: ['Test log message'], + timestamp: Date.now() + } + ], + exceptions: [], + diagnosticsChannelEvents: [], + scriptVersion: { id: 'test-version' }, + dispatchNamespace: undefined, + scriptTags: [], + ...overrides + } as TraceItem +} + +// ----------------------------------------------------------------------------- +// Tail Handler Tests +// ----------------------------------------------------------------------------- + +describe('Tail Handler with Real KV', () => { + test('processes trace items and stores in KV', async () => { + const event = createTraceItem({ + scriptName: 'store-test', + eventTimestamp: 1000001 + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + expect(result.itemCount).toBe(1) + + // Verify log was stored in real KV + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + + expect(stored).not.toBeNull() + + const entry: LogEntry = JSON.parse(stored!) + expect(entry.scriptName).toBe('store-test') + expect(entry.outcome).toBe('ok') + expect(entry.logs).toHaveLength(1) + expect(entry.logs[0].message).toEqual(['Test log message']) + }) + + test('extracts request info from trace item', async () => { + const event = createTraceItem({ + scriptName: 'request-test', + eventTimestamp: 1000002, + event: { + request: { + url: 'https://api.example.com/users', + method: 'POST' + } + } + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + const entry: LogEntry = JSON.parse(stored!) + + expect(entry.request).toBeDefined() + expect(entry.request?.url).toBe('https://api.example.com/users') + expect(entry.request?.method).toBe('POST') + }) + + test('processes exceptions', async () => { + const event = createTraceItem({ + scriptName: 'exception-test', + eventTimestamp: 1000003, + outcome: 'exception', + logs: [], + exceptions: [ + { + name: 'Error', + message: 'Something went wrong', + timestamp: Date.now() + } + ] + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + const entry: LogEntry = JSON.parse(stored!) + + expect(entry.outcome).toBe('exception') + expect(entry.exceptions).toHaveLength(1) + expect(entry.exceptions[0].name).toBe('Error') + expect(entry.exceptions[0].message).toBe('Something went wrong') + }) + + test('processes multiple trace items', async () => { + const events = [ + createTraceItem({ scriptName: 'worker-a', eventTimestamp: 2000001 }), + createTraceItem({ scriptName: 'worker-b', eventTimestamp: 2000002 }), + createTraceItem({ scriptName: 'worker-c', eventTimestamp: 2000003 }) + ] + + const result = await cf.tail.trigger(events) + expect(result.success).toBe(true) + expect(result.itemCount).toBe(3) + + // Verify all were stored + for (const event of events) { + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + expect(stored).not.toBeNull() + } + }) +}) + +// ----------------------------------------------------------------------------- +// Log Level Filtering Tests (Pure Logic) +// ----------------------------------------------------------------------------- + +describe('Log Level Filtering (Pure Logic)', () => { + // Test the filtering logic directly without needing different env configurations + // The filterByLevel function is internal, so we test through processable scenarios + + test('default min level (log) filters debug messages', async () => { + const timestamp = Date.now() + 3000000 + const event = createTraceItem({ + scriptName: 'level-filter-test', + eventTimestamp: timestamp, + logs: [ + { level: 'debug', message: ['Debug message'], timestamp: Date.now() }, + { level: 'log', message: ['Log message'], timestamp: Date.now() }, + { level: 'warn', message: ['Warn message'], timestamp: Date.now() }, + { level: 'error', message: ['Error message'], timestamp: Date.now() } + ] + }) + + // env has default MIN_LOG_LEVEL = 'log' from config + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + const entry: LogEntry = JSON.parse(stored!) + + // Should have log, warn, error but NOT debug + expect(entry.logs).toHaveLength(3) + expect(entry.logs.map((l) => l.level)).toEqual(['log', 'warn', 'error']) + }) + + test('stores entries with only exceptions (no logs)', async () => { + const timestamp = Date.now() + 3000001 + const event = createTraceItem({ + scriptName: 'exception-only-test', + eventTimestamp: timestamp, + logs: [], + exceptions: [ + { name: 'Error', message: 'Something failed', timestamp: Date.now() } + ] + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + const entry: LogEntry = JSON.parse(stored!) + + expect(entry.logs).toHaveLength(0) + expect(entry.exceptions).toHaveLength(1) + }) + + test('skips entries with no logs after filtering', async () => { + const timestamp = Date.now() + 3000002 + // Create event with only debug logs (will be filtered by default 'log' level) + const event = createTraceItem({ + scriptName: 'skip-empty-test', + eventTimestamp: timestamp, + logs: [ + { level: 'debug', message: ['Only debug'], timestamp: Date.now() } + ], + exceptions: [] + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + + // Should not be stored since no logs pass the filter and no exceptions + expect(stored).toBeNull() + }) +}) + +// ----------------------------------------------------------------------------- +// Edge Cases +// ----------------------------------------------------------------------------- + +describe('Edge Cases with Real KV', () => { + test('handles empty events array', async () => { + const result = await cf.tail.trigger([]) + expect(result.success).toBe(true) + expect(result.itemCount).toBe(0) + }) + + test('handles trace item with no logs or exceptions', async () => { + const timestamp = Date.now() + 200000 + const event = createTraceItem({ + scriptName: 'no-logs-test', + eventTimestamp: timestamp, + logs: [], + exceptions: [] + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + + // Should not be stored + expect(stored).toBeNull() + }) + + test('handles trace item without event property', async () => { + const timestamp = Date.now() + 300000 + const event = createTraceItem({ + scriptName: 'no-event-test', + eventTimestamp: timestamp, + event: undefined as unknown as TraceItem['event'], + exceptions: [ + { name: 'Error', message: 'Test', timestamp: Date.now() } + ] + }) + + const result = await cf.tail.trigger([event]) + expect(result.success).toBe(true) + + const key = `tail:${event.scriptName}-${event.eventTimestamp}` + const stored = await env.LOG_STORE.get(key) + const entry: LogEntry = JSON.parse(stored!) + + expect(entry.request).toBeUndefined() + expect(entry.exceptions).toHaveLength(1) + }) +}) diff --git a/cases/case13/tsconfig.json b/cases/case13/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case13/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case14/devflare.config.ts b/cases/case14/devflare.config.ts new file mode 100644 index 0000000..05f4756 --- /dev/null +++ b/cases/case14/devflare.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case14-hyperdrive', + compatibilityDate: '2026-04-26', + + bindings: { + // Hyperdrive for PostgreSQL connection pooling + // Prefer the stable configured name over a raw Hyperdrive configuration id + hyperdrive: { + DB: 'devflare-testing' + } + } +}) diff --git a/cases/case14/env.d.ts b/cases/case14/env.d.ts new file mode 100644 index 0000000..c0ff84d --- /dev/null +++ b/cases/case14/env.d.ts @@ -0,0 +1,16 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { Hyperdrive } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + DB: Hyperdrive + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case14/package.json b/cases/case14/package.json new file mode 100644 index 0000000..35896f4 --- /dev/null +++ b/cases/case14/package.json @@ -0,0 +1,16 @@ +{ + "name": "@devflare/case14-hyperdrive", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case14/src/fetch.ts b/cases/case14/src/fetch.ts new file mode 100644 index 0000000..d19b4a0 --- /dev/null +++ b/cases/case14/src/fetch.ts @@ -0,0 +1,48 @@ +// ============================================================================= +// Case 14: Hyperdrive โ€” Fetch Handler +// ============================================================================= +// Demonstrates PostgreSQL access via Hyperdrive connection pooling. +// In production, Hyperdrive provides optimized connection pooling to PostgreSQL. +// In local dev, Hyperdrive binding provides a connectionString for direct access. +// ============================================================================= + +import { env } from 'devflare' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface User { + id: number + name: string + email: string + created_at: string +} + +// ----------------------------------------------------------------------------- +// Fetch Handler +// ----------------------------------------------------------------------------- + +/** + * HTTP fetch handler demonstrating Hyperdrive usage. + * Hyperdrive provides a `connectionString` for PostgreSQL connections. + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/health') { + return Response.json({ status: 'ok', binding: 'hyperdrive' }) + } + + if (url.pathname === '/connection-info') { + // Access Hyperdrive binding โ€” provides connectionString for PostgreSQL + // In a real app, you'd use this with a PostgreSQL driver: + // const sql = postgres(env.DB.connectionString) + return Response.json({ + hasBinding: Boolean(env.DB), + hasConnectionString: Boolean(env.DB?.connectionString) + }) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case14/tests/hyperdrive.test.ts b/cases/case14/tests/hyperdrive.test.ts new file mode 100644 index 0000000..719d655 --- /dev/null +++ b/cases/case14/tests/hyperdrive.test.ts @@ -0,0 +1,68 @@ +// ============================================================================= +// Case 14: Hyperdrive โ€” Tests +// ============================================================================= +// Tests using devflare test utilities to verify Hyperdrive binding. +// Hyperdrive provides a connectionString for PostgreSQL access. +// +// Note: Miniflare's Hyperdrive stub provides `connectionString` as a property. +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext, env } from 'devflare/test' +import fetch from '../src/fetch' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +// ----------------------------------------------------------------------------- +// Hyperdrive Binding Tests +// ----------------------------------------------------------------------------- + +describe('Hyperdrive Binding', () => { + test('should have DB binding available', () => { + expect(env.DB).toBeDefined() + }) + + test('should have connectionString property', () => { + // Hyperdrive binding provides connectionString + // Miniflare may provide it as a getter or property + expect(env.DB.connectionString).toBeDefined() + }) +}) + +// ----------------------------------------------------------------------------- +// Fetch Handler Tests +// ----------------------------------------------------------------------------- + +describe('Fetch Handler', () => { + test('should return health status', async () => { + const request = new Request('http://localhost/health') + const response = await fetch(request) + + expect(response.status).toBe(200) + const body = await response.json() as { status: string; binding: string } + expect(body.status).toBe('ok') + expect(body.binding).toBe('hyperdrive') + }) + + test('should report Hyperdrive connection info', async () => { + const request = new Request('http://localhost/connection-info') + const response = await fetch(request) + + expect(response.status).toBe(200) + const body = await response.json() as { hasBinding: boolean, hasConnectionString: boolean } + expect(body.hasBinding).toBe(true) + expect(body.hasConnectionString).toBe(true) + }) + + test('should return 404 for unknown routes', async () => { + const request = new Request('http://localhost/unknown') + const response = await fetch(request) + + expect(response.status).toBe(404) + }) +}) diff --git a/cases/case14/tsconfig.json b/cases/case14/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case14/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case15/devflare.config.ts b/cases/case15/devflare.config.ts new file mode 100644 index 0000000..bc40df1 --- /dev/null +++ b/cases/case15/devflare.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'devflare/config' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: 'case15-ai-vectorize', + compatibilityDate: '2026-04-26', + accountId, + + bindings: { + // AI binding โ€” use Devflare remote mode for real inference + // No local simulation exists for GPU inference + ai: { + binding: 'AI' + }, + + // Vectorize binding โ€” use Devflare remote mode for real queries + // Vector database is a managed service + vectorize: { + VECTORIZE: { + indexName: 'embeddings-index' + } + }, + + // KV for caching embeddings locally + kv: { + CACHE: 'cache-kv-id' + } + }, + + vars: { + // Model to use for embeddings + EMBEDDING_MODEL: '@cf/baai/bge-base-en-v1.5', + // Model for text generation + TEXT_MODEL: '@cf/meta/llama-3.1-8b-instruct' + } +}) diff --git a/cases/case15/env.d.ts b/cases/case15/env.d.ts new file mode 100644 index 0000000..3262382 --- /dev/null +++ b/cases/case15/env.d.ts @@ -0,0 +1,22 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { Ai, KVNamespace, VectorizeIndex } from '@cloudflare/workers-types' + +import type { InferConfigVars } from 'devflare/config' +type __DevflareConfigVars = InferConfigVars> + +declare global { + interface DevflareVars extends __DevflareConfigVars {} + interface DevflareEnv extends __DevflareConfigVars { + CACHE: KVNamespace + AI: Ai + VECTORIZE: VectorizeIndex + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case15/package.json b/cases/case15/package.json new file mode 100644 index 0000000..897ba24 --- /dev/null +++ b/cases/case15/package.json @@ -0,0 +1,16 @@ +{ + "name": "@devflare/case15-ai-vectorize", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case15/src/fetch.ts b/cases/case15/src/fetch.ts new file mode 100644 index 0000000..ffc43b0 --- /dev/null +++ b/cases/case15/src/fetch.ts @@ -0,0 +1,219 @@ +// ============================================================================= +// Case 15: AI & Vectorize โ€” Fetch Handler +// ============================================================================= +// Demonstrates AI inference and vector search using Cloudflare bindings. +// NOTE: AI and Vectorize ALWAYS require `remote: true` โ€” no local simulation. +// ============================================================================= + +import { env } from 'devflare' + +/** + * Embedding result from AI model + */ +interface EmbeddingResult { + shape: number[] + data: number[][] +} + +/** + * Text generation result + */ +interface TextGenerationResult { + response: string +} + +/** + * Vector search match + */ +interface VectorMatch { + id: string + score: number + metadata?: Record +} + +/** + * Generate embeddings for text using AI binding + */ +export async function generateEmbedding( + ai: DevflareEnv['AI'], + text: string, + model: string +): Promise { + const result = await ai.run(model as keyof AiModels, { text: [text] }) as EmbeddingResult + return result.data[0] +} + +/** + * Generate text using AI binding + */ +export async function generateText( + ai: DevflareEnv['AI'], + prompt: string, + model: string, + options?: { maxTokens?: number; temperature?: number } +): Promise { + const result = await ai.run(model as keyof AiModels, { + prompt, + max_tokens: options?.maxTokens ?? 256, + temperature: options?.temperature ?? 0.7 + }) as TextGenerationResult + + return result.response +} + +/** + * Search for similar vectors + */ +export async function searchSimilar( + vectorize: DevflareEnv['VECTORIZE'], + embedding: number[], + topK = 5 +): Promise { + const result = await vectorize.query(embedding, { + topK, + returnMetadata: 'all' + }) + + return result.matches.map((match) => ({ + id: match.id, + score: match.score, + metadata: match.metadata as Record | undefined + })) +} + +/** + * Insert vector into index + */ +export async function insertVector( + vectorize: DevflareEnv['VECTORIZE'], + id: string, + embedding: number[], + metadata?: Record +): Promise { + await vectorize.upsert([ + { + id, + values: embedding, + metadata + } + ]) +} + +/** + * Fetch handler for AI & Vectorize demo + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + // Generate embedding for text + if (url.pathname === '/embed') { + const text = url.searchParams.get('text') + if (!text) { + return Response.json({ error: 'Missing text parameter' }, { status: 400 }) + } + + const embedding = await generateEmbedding(env.AI, text, env.EMBEDDING_MODEL) + return Response.json({ embedding, dimensions: embedding.length }) + } + + // Generate text completion + if (url.pathname === '/generate') { + const prompt = url.searchParams.get('prompt') + if (!prompt) { + return Response.json({ error: 'Missing prompt parameter' }, { status: 400 }) + } + + const response = await generateText(env.AI, prompt, env.TEXT_MODEL) + return Response.json({ response }) + } + + // Semantic search + if (url.pathname === '/search') { + const query = url.searchParams.get('q') + if (!query) { + return Response.json({ error: 'Missing query parameter' }, { status: 400 }) + } + + // Generate embedding for query + const queryEmbedding = await generateEmbedding(env.AI, query, env.EMBEDDING_MODEL) + + // Search for similar vectors + const matches = await searchSimilar(env.VECTORIZE, queryEmbedding) + + return Response.json({ query, matches }) + } + + // Index a document + if (url.pathname === '/index' && request.method === 'POST') { + const body = await request.json() as { + id: string + text: string + metadata?: Record + } + + // Generate embedding + const embedding = await generateEmbedding(env.AI, body.text, env.EMBEDDING_MODEL) + + // Store in Vectorize + await insertVector(env.VECTORIZE, body.id, embedding, { + ...body.metadata, + text: body.text + }) + + return Response.json({ success: true, id: body.id }) + } + + // RAG (Retrieval Augmented Generation) + if (url.pathname === '/rag') { + const query = url.searchParams.get('q') + if (!query) { + return Response.json({ error: 'Missing query parameter' }, { status: 400 }) + } + + // Step 1: Generate embedding for query + const queryEmbedding = await generateEmbedding(env.AI, query, env.EMBEDDING_MODEL) + + // Step 2: Search for relevant documents + const matches = await searchSimilar(env.VECTORIZE, queryEmbedding, 3) + + // Step 3: Build context from retrieved documents + const context = matches + .map((m) => m.metadata?.text as string ?? '') + .filter(Boolean) + .join('\n\n') + + // Step 4: Generate response with context + const prompt = `Based on the following context, answer the question. + +Context: +${context} + +Question: ${query} + +Answer:` + + const response = await generateText(env.AI, prompt, env.TEXT_MODEL, { + maxTokens: 512 + }) + + return Response.json({ + query, + context: matches.map((m) => ({ + id: m.id, + score: m.score, + text: m.metadata?.text + })), + response + }) + } + + return Response.json({ + endpoints: [ + 'GET /embed?text=...', + 'GET /generate?prompt=...', + 'GET /search?q=...', + 'POST /index { id, text, metadata }', + 'GET /rag?q=...' + ] + }) +} diff --git a/cases/case15/tests/ai-vectorize.test.ts b/cases/case15/tests/ai-vectorize.test.ts new file mode 100644 index 0000000..b09b3d7 --- /dev/null +++ b/cases/case15/tests/ai-vectorize.test.ts @@ -0,0 +1,130 @@ +// ============================================================================= +// Case 15: AI & Vectorize โ€” Tests +// ============================================================================= +// These tests require remote mode because AI and Vectorize +// CANNOT be emulated locally โ€” they need real Cloudflare infrastructure. +// +// To enable remote mode for 30 minutes: +// devflare remote enable +// +// Then run: +// bun test cases/case15 +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' +import { + generateEmbedding, + generateText, + searchSimilar, + insertVector +} from '../src/fetch' +import fetchHandler from '../src/fetch' + +// Skip conditions resolved in parallel at module load +const [skipAI, skipVectorize] = await Promise.all([ + shouldSkip.ai, + shouldSkip.vectorize +]) + +const requiresRemoteContext = !skipAI || !skipVectorize + +// ----------------------------------------------------------------------------- +// Test Setup โ€” Only create a runtime context when a remote suite will run +// ----------------------------------------------------------------------------- +// AI and Vectorize are remote-only services. In default local/CI validation runs +// those suites are skipped, and the module smoke tests below do not need a live +// runtime context. Keeping the setup conditional avoids unnecessary bridge/ +// Miniflare startup for a file whose real integration coverage is explicitly +// gated behind remote mode. + +if (requiresRemoteContext) { + beforeAll(() => createTestContext()) + afterAll(() => env.dispose()) +} + +// ----------------------------------------------------------------------------- +// Models โ€” Using cheapest options for testing +// ----------------------------------------------------------------------------- + +// Cheapest embedding model for testing +const CHEAP_EMBEDDING = '@cf/baai/bge-small-en-v1.5' +const VECTOR_DIMS = 384 + +// Cheapest LLM for testing +const CHEAP_LLM = '@cf/meta/llama-3.2-1b-instruct' + +// ----------------------------------------------------------------------------- +// AI Tests โ€” Require Remote +// ----------------------------------------------------------------------------- + +describe.skipIf(skipAI)('AI Integration', () => { + test('generateEmbedding returns vector', async () => { + const embedding = await generateEmbedding(env.AI, 'Hello world', CHEAP_EMBEDDING) + + expect(Array.isArray(embedding)).toBe(true) + expect(embedding.length).toBe(VECTOR_DIMS) + expect(typeof embedding[0]).toBe('number') + }) + + test('generateText returns string', async () => { + const response = await generateText(env.AI, 'Say hello', CHEAP_LLM, { maxTokens: 10 }) + + expect(typeof response).toBe('string') + expect(response.length).toBeGreaterThan(0) + }) +}) + +// ----------------------------------------------------------------------------- +// Vectorize Tests โ€” Require Remote + Index Setup +// ----------------------------------------------------------------------------- +// NOTE: These tests require a Vectorize index named "embeddings-index" to exist. +// Create it with: wrangler vectorize create embeddings-index --dimensions=384 --metric=cosine +// ----------------------------------------------------------------------------- + +describe.skipIf(skipVectorize)('Vectorize Integration', () => { + test('insertVector and searchSimilar work', async () => { + const testId = `test-${Date.now()}` + const testVector = Array(VECTOR_DIMS).fill(0.5) + + try { + // Insert + await insertVector(env.VECTORIZE, testId, testVector, { text: 'Test doc' }) + + // Search + const matches = await searchSimilar(env.VECTORIZE, testVector, 5) + + expect(Array.isArray(matches)).toBe(true) + expect(matches.length).toBeGreaterThan(0) + expect(matches[0]).toHaveProperty('id') + expect(matches[0]).toHaveProperty('score') + } catch (error) { + // If the index doesn't exist, skip with a helpful message + if (error instanceof Error && error.message.includes('index was not found')) { + console.log('โญ๏ธ Vectorize test skipped: Index "embeddings-index" not found.') + console.log(' Create it with: wrangler vectorize create embeddings-index --dimensions=384 --metric=cosine') + return + } + throw error + } + }) +}) + +// ----------------------------------------------------------------------------- +// Module Smoke Test โ€” Always Runs +// ----------------------------------------------------------------------------- + +describe('Module Smoke Test', () => { + test('fetch handler exports are valid', () => { + expect(typeof fetchHandler).toBe('function') + }) + + test('utility functions are exported', () => { + expect(typeof generateEmbedding).toBe('function') + expect(typeof generateText).toBe('function') + expect(typeof searchSimilar).toBe('function') + expect(typeof insertVector).toBe('function') + }) +}) + + diff --git a/cases/case15/tsconfig.json b/cases/case15/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case15/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case16/devflare.config.ts b/cases/case16/devflare.config.ts new file mode 100644 index 0000000..e1f6291 --- /dev/null +++ b/cases/case16/devflare.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case16-workflows', + compatibilityDate: '2026-04-26', + + files: { + // Workflow classes use wf.*.ts pattern (intentionally more restrictive than default) + workflows: 'src/wf.*.ts', + // Transport for custom type serialization + transport: 'src/transport.ts' + }, + + bindings: { + // KV for storing workflow state and results + kv: { + WORKFLOW_STATE: 'workflow-state-kv-id', + RESULTS: 'results-kv-id' + } + }, + + vars: { + // Default retry configuration + MAX_RETRIES: '3', + RETRY_DELAY_MS: '1000' + } +}) diff --git a/cases/case16/env.d.ts b/cases/case16/env.d.ts new file mode 100644 index 0000000..6b60e0f --- /dev/null +++ b/cases/case16/env.d.ts @@ -0,0 +1,21 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace } from '@cloudflare/workers-types' + +import type { InferConfigVars } from 'devflare/config' +type __DevflareConfigVars = InferConfigVars> + +declare global { + interface DevflareVars extends __DevflareConfigVars {} + interface DevflareEnv extends __DevflareConfigVars { + WORKFLOW_STATE: KVNamespace + RESULTS: KVNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case16/package.json b/cases/case16/package.json new file mode 100644 index 0000000..2b84163 --- /dev/null +++ b/cases/case16/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case16-workflows", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case16/src/fetch.ts b/cases/case16/src/fetch.ts new file mode 100644 index 0000000..8d224fe --- /dev/null +++ b/cases/case16/src/fetch.ts @@ -0,0 +1,124 @@ +// ============================================================================= +// Case 16: Workflows โ€” Fetch Handler +// ============================================================================= +// HTTP handler for triggering and managing workflows. +// ============================================================================= + +import { env } from 'devflare' +import { OrderProcessingWorkflow, type OrderProcessingInput } from './wf.order-processor' +import { DataPipelineWorkflow, type DataPipelineInput } from './wf.data-pipeline' +import type { WorkflowStep } from './wf.order-processor' + +/** + * Create a mock workflow step for testing + */ +function createMockStep(): WorkflowStep { + return { + async do(name: string, fn: () => T | Promise): Promise { + return fn() + }, + async sleep(name: string, duration: string): Promise { + // Parse duration and sleep + const ms = parseDuration(duration) + await new Promise((resolve) => setTimeout(resolve, Math.min(ms, 100))) + }, + async sleepUntil(name: string, timestamp: Date | string): Promise { + // No-op for testing + }, + async waitForEvent(name: string, options: { event: string; timeout: string }): Promise { + return {} as T + } + } +} + +/** + * Parse duration string to milliseconds + */ +function parseDuration(duration: string): number { + const match = duration.match(/^(\d+)(ms|s|m|h)$/) + if (!match) return 0 + + const value = parseInt(match[1], 10) + const unit = match[2] + + switch (unit) { + case 'ms': return value + case 's': return value * 1000 + case 'm': return value * 60 * 1000 + case 'h': return value * 60 * 60 * 1000 + default: return 0 + } +} + +/** + * Fetch handler for workflow management + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + // Trigger order processing workflow + if (url.pathname === '/workflows/order' && request.method === 'POST') { + const input = await request.json() as OrderProcessingInput + + const workflow = new OrderProcessingWorkflow(env as unknown as DevflareEnv) + const step = createMockStep() + + const result = await workflow.run( + { params: input, timestamp: new Date() }, + step + ) + + return Response.json(result) + } + + // Trigger data pipeline workflow + if (url.pathname === '/workflows/pipeline' && request.method === 'POST') { + const input = await request.json() as DataPipelineInput + + const workflow = new DataPipelineWorkflow(env as unknown as DevflareEnv) + const step = createMockStep() + + const result = await workflow.run( + { params: input, timestamp: new Date() }, + step + ) + + return Response.json(result) + } + + // Get workflow status + if (url.pathname.startsWith('/workflows/status/')) { + const workflowId = url.pathname.replace('/workflows/status/', '') + const state = await env.WORKFLOW_STATE.get(`workflow:${workflowId}`) + + if (!state) { + return Response.json({ error: 'Workflow not found' }, { status: 404 }) + } + + return Response.json(JSON.parse(state)) + } + + // List recent workflows + if (url.pathname === '/workflows') { + const list = await env.WORKFLOW_STATE.list({ prefix: 'workflow:' }) + const workflows = [] + + for (const key of list.keys.slice(0, 10)) { + const state = await env.WORKFLOW_STATE.get(key.name) + if (state) { + workflows.push(JSON.parse(state)) + } + } + + return Response.json({ workflows }) + } + + return Response.json({ + endpoints: [ + 'POST /workflows/order - Trigger order processing', + 'POST /workflows/pipeline - Trigger data pipeline', + 'GET /workflows/status/:id - Get workflow status', + 'GET /workflows - List recent workflows' + ] + }) +} diff --git a/cases/case16/src/models.ts b/cases/case16/src/models.ts new file mode 100644 index 0000000..9dfcd1b --- /dev/null +++ b/cases/case16/src/models.ts @@ -0,0 +1,267 @@ +// ============================================================================= +// Case 16: Workflows โ€” Models +// ============================================================================= +// Domain models for workflow data that require transport encoding/decoding. +// These classes have methods and behavior beyond plain data. +// ============================================================================= + +/** + * Order item data structure + */ +export interface OrderItemData { + productId: string + name: string + quantity: number + price: number +} + +/** + * Order data structure for serialization + */ +export interface OrderData { + id: string + customerId: string + items: OrderItemData[] + status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' + createdAt: string + updatedAt: string +} + +/** + * Order class with business logic + */ +export class Order { + readonly id: string + readonly customerId: string + readonly items: OrderItemData[] + status: OrderData['status'] + readonly createdAt: Date + updatedAt: Date + + constructor(data: OrderData) { + this.id = data.id + this.customerId = data.customerId + this.items = data.items + this.status = data.status + this.createdAt = new Date(data.createdAt) + this.updatedAt = new Date(data.updatedAt) + } + + /** + * Calculate total order value + */ + get total(): number { + return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0) + } + + /** + * Get item count + */ + get itemCount(): number { + return this.items.reduce((sum, item) => sum + item.quantity, 0) + } + + /** + * Check if order can be cancelled + */ + canCancel(): boolean { + return this.status === 'pending' || this.status === 'processing' + } + + /** + * Update order status + */ + updateStatus(newStatus: OrderData['status']): void { + this.status = newStatus + this.updatedAt = new Date() + } + + /** + * Convert to plain data for serialization + */ + toData(): OrderData { + return { + id: this.id, + customerId: this.customerId, + items: this.items, + status: this.status, + createdAt: this.createdAt.toISOString(), + updatedAt: this.updatedAt.toISOString() + } + } +} + +/** + * Workflow step result data + */ +export interface StepResultData { + stepName: string + success: boolean + output?: unknown + error?: string + startedAt: string + completedAt: string + retryCount: number +} + +/** + * Workflow step result with computed properties + */ +export class StepResult { + readonly stepName: string + readonly success: boolean + readonly output?: unknown + readonly error?: string + readonly startedAt: Date + readonly completedAt: Date + readonly retryCount: number + + constructor(data: StepResultData) { + this.stepName = data.stepName + this.success = data.success + this.output = data.output + this.error = data.error + this.startedAt = new Date(data.startedAt) + this.completedAt = new Date(data.completedAt) + this.retryCount = data.retryCount + } + + /** + * Get step duration in milliseconds + */ + get durationMs(): number { + return this.completedAt.getTime() - this.startedAt.getTime() + } + + /** + * Check if step had to retry + */ + get hadRetries(): boolean { + return this.retryCount > 0 + } + + /** + * Convert to plain data + */ + toData(): StepResultData { + return { + stepName: this.stepName, + success: this.success, + output: this.output, + error: this.error, + startedAt: this.startedAt.toISOString(), + completedAt: this.completedAt.toISOString(), + retryCount: this.retryCount + } + } +} + +/** + * Workflow instance data + */ +export interface WorkflowInstanceData { + id: string + workflowName: string + status: 'running' | 'completed' | 'failed' | 'paused' + currentStep: string + steps: StepResultData[] + input: unknown + output?: unknown + error?: string + startedAt: string + completedAt?: string +} + +/** + * Workflow instance with tracking + */ +export class WorkflowInstance { + readonly id: string + readonly workflowName: string + status: WorkflowInstanceData['status'] + currentStep: string + readonly steps: StepResult[] + readonly input: unknown + output?: unknown + error?: string + readonly startedAt: Date + completedAt?: Date + + constructor(data: WorkflowInstanceData) { + this.id = data.id + this.workflowName = data.workflowName + this.status = data.status + this.currentStep = data.currentStep + this.steps = data.steps.map((s) => new StepResult(s)) + this.input = data.input + this.output = data.output + this.error = data.error + this.startedAt = new Date(data.startedAt) + this.completedAt = data.completedAt ? new Date(data.completedAt) : undefined + } + + /** + * Get total workflow duration + */ + get durationMs(): number | undefined { + if (!this.completedAt) return undefined + return this.completedAt.getTime() - this.startedAt.getTime() + } + + /** + * Get successful step count + */ + get successfulSteps(): number { + return this.steps.filter((s) => s.success).length + } + + /** + * Get failed step count + */ + get failedSteps(): number { + return this.steps.filter((s) => !s.success).length + } + + /** + * Add step result + */ + addStep(step: StepResult): void { + this.steps.push(step) + } + + /** + * Mark as completed + */ + complete(output: unknown): void { + this.status = 'completed' + this.output = output + this.completedAt = new Date() + } + + /** + * Mark as failed + */ + fail(error: string): void { + this.status = 'failed' + this.error = error + this.completedAt = new Date() + } + + /** + * Convert to plain data + */ + toData(): WorkflowInstanceData { + return { + id: this.id, + workflowName: this.workflowName, + status: this.status, + currentStep: this.currentStep, + steps: this.steps.map((s) => s.toData()), + input: this.input, + output: this.output, + error: this.error, + startedAt: this.startedAt.toISOString(), + completedAt: this.completedAt?.toISOString() + } + } +} diff --git a/cases/case16/src/transport.ts b/cases/case16/src/transport.ts new file mode 100644 index 0000000..b2c2e91 --- /dev/null +++ b/cases/case16/src/transport.ts @@ -0,0 +1,35 @@ +// ============================================================================= +// Case 16: Workflows โ€” Transport +// ============================================================================= +// Transport object for encoding/decoding custom types across RPC boundaries. +// This follows the SvelteKit signature pattern used in devflare. +// ============================================================================= + +import { + Order, + StepResult, + WorkflowInstance, + type OrderData, + type StepResultData, + type WorkflowInstanceData +} from './models' + +export const transport = { + Order: { + encode: (v: unknown): OrderData | false => + v instanceof Order && v.toData(), + decode: (v: OrderData) => new Order(v) + }, + + StepResult: { + encode: (v: unknown): StepResultData | false => + v instanceof StepResult && v.toData(), + decode: (v: StepResultData) => new StepResult(v) + }, + + WorkflowInstance: { + encode: (v: unknown): WorkflowInstanceData | false => + v instanceof WorkflowInstance && v.toData(), + decode: (v: WorkflowInstanceData) => new WorkflowInstance(v) + } +} diff --git a/cases/case16/src/wf.data-pipeline.ts b/cases/case16/src/wf.data-pipeline.ts new file mode 100644 index 0000000..ab32154 --- /dev/null +++ b/cases/case16/src/wf.data-pipeline.ts @@ -0,0 +1,259 @@ +// ============================================================================= +// Case 16: Workflows โ€” Data Pipeline Workflow +// ============================================================================= +// Demonstrates a data processing workflow with ETL steps. +// Uses wf.*.ts naming convention. +// ============================================================================= + +import { StepResult, WorkflowInstance } from './models' +import type { WorkflowEvent, WorkflowStep } from './wf.order-processor' + +/** + * Data pipeline input + */ +export interface DataPipelineInput { + sourceId: string + sourcePath: string + destinationPath: string + transformations: string[] +} + +/** + * Data pipeline output + */ +export interface DataPipelineOutput { + recordsProcessed: number + recordsFailed: number + outputPath: string + duration: number +} + +/** + * Data record type + */ +interface DataRecord { + id: string + data: Record + timestamp: string +} + +/** + * Data Pipeline Workflow + * + * Steps: + * 1. Extract data from source + * 2. Transform data (apply transformations) + * 3. Validate transformed data + * 4. Load data to destination + */ +export class DataPipelineWorkflow { + protected env: DevflareEnv + + constructor(env: DevflareEnv) { + this.env = env + } + + /** + * Main workflow execution + */ + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { sourceId, sourcePath, destinationPath, transformations } = event.params + const startTime = Date.now() + + // Create workflow instance + const instance = new WorkflowInstance({ + id: `pipeline-${sourceId}-${Date.now()}`, + workflowName: 'DataPipelineWorkflow', + status: 'running', + currentStep: 'extract', + steps: [], + input: event.params, + startedAt: new Date().toISOString() + }) + + try { + // Step 1: Extract + instance.currentStep = 'extract' + const extracted = await step.do('extract-data', async (): Promise => { + // Simulate data extraction + const records: DataRecord[] = Array.from({ length: 100 }, (_, i) => ({ + id: `record-${i}`, + data: { + value: Math.random() * 100, + category: ['A', 'B', 'C'][i % 3], + source: sourcePath + }, + timestamp: new Date().toISOString() + })) + + return records + }) + + instance.addStep(new StepResult({ + stepName: 'extract-data', + success: true, + output: { recordCount: extracted.length }, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 2: Transform (apply each transformation) + instance.currentStep = 'transform' + let transformedData = extracted + + for (const transformation of transformations) { + transformedData = await step.do( + `transform-${transformation}`, + async (): Promise => { + return this.applyTransformation(transformedData, transformation) + } + ) + + instance.addStep(new StepResult({ + stepName: `transform-${transformation}`, + success: true, + output: { transformation, recordCount: transformedData.length }, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + } + + // Step 3: Validate + instance.currentStep = 'validate' + const validation = await step.do('validate-data', async () => { + const valid = transformedData.filter((r) => this.validateRecord(r)) + const invalid = transformedData.length - valid.length + + return { + validRecords: valid.length, + invalidRecords: invalid, + records: valid + } + }) + + instance.addStep(new StepResult({ + stepName: 'validate-data', + success: true, + output: { + validRecords: validation.validRecords, + invalidRecords: validation.invalidRecords + }, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 4: Load + instance.currentStep = 'load' + await step.do('load-data', async () => { + // Simulate loading to destination + await this.env.RESULTS.put( + `pipeline:${sourceId}:${Date.now()}`, + JSON.stringify({ + path: destinationPath, + recordCount: validation.validRecords, + completedAt: new Date().toISOString() + }) + ) + + return { loaded: validation.validRecords } + }) + + instance.addStep(new StepResult({ + stepName: 'load-data', + success: true, + output: { loaded: validation.validRecords }, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Complete workflow + const output: DataPipelineOutput = { + recordsProcessed: validation.validRecords, + recordsFailed: validation.invalidRecords, + outputPath: destinationPath, + duration: Date.now() - startTime + } + + instance.complete(output) + + await this.env.WORKFLOW_STATE.put( + `workflow:${instance.id}`, + JSON.stringify(instance.toData()) + ) + + return output + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + instance.fail(errorMessage) + + await this.env.WORKFLOW_STATE.put( + `workflow:${instance.id}`, + JSON.stringify(instance.toData()) + ) + + return { + recordsProcessed: 0, + recordsFailed: 0, + outputPath: '', + duration: Date.now() - startTime + } + } + } + + /** + * Apply a transformation to data + */ + private applyTransformation(data: DataRecord[], transformation: string): DataRecord[] { + switch (transformation) { + case 'normalize': + return data.map((r) => ({ + ...r, + data: { + ...r.data, + value: typeof r.data.value === 'number' ? r.data.value / 100 : r.data.value + } + })) + + case 'filter': + return data.filter((r) => { + const value = r.data.value as number + return value > 0.2 + }) + + case 'enrich': + return data.map((r) => ({ + ...r, + data: { + ...r.data, + enrichedAt: new Date().toISOString(), + version: '1.0' + } + })) + + default: + return data + } + } + + /** + * Validate a single record + */ + private validateRecord(record: DataRecord): boolean { + return ( + typeof record.id === 'string' && + record.id.length > 0 && + record.data !== null && + typeof record.data === 'object' + ) + } +} + +export default DataPipelineWorkflow diff --git a/cases/case16/src/wf.order-processor.ts b/cases/case16/src/wf.order-processor.ts new file mode 100644 index 0000000..59c8436 --- /dev/null +++ b/cases/case16/src/wf.order-processor.ts @@ -0,0 +1,241 @@ +// ============================================================================= +// Case 16: Workflows โ€” Order Processing Workflow +// ============================================================================= +// Demonstrates a multi-step workflow for order processing. +// Uses wf.*.ts naming convention (like do.*.ts for Durable Objects). +// ============================================================================= + +import { Order, StepResult, WorkflowInstance, type OrderData } from './models' + +/** + * Workflow event containing input parameters + */ +export interface WorkflowEvent { + params: T + timestamp: Date +} + +/** + * Workflow step interface for durable execution + */ +export interface WorkflowStep { + do(name: string, fn: () => T | Promise): Promise + sleep(name: string, duration: string): Promise + sleepUntil(name: string, timestamp: Date | string): Promise + waitForEvent(name: string, options: { event: string; timeout: string }): Promise +} + +/** + * Order processing workflow input + */ +export interface OrderProcessingInput { + orderId: string + order: OrderData +} + +/** + * Order processing workflow output + */ +export interface OrderProcessingOutput { + orderId: string + success: boolean + shippingLabel?: string + trackingNumber?: string + error?: string +} + +/** + * Order Processing Workflow + * + * Steps: + * 1. Validate order + * 2. Reserve inventory + * 3. Process payment + * 4. Generate shipping label + * 5. Send confirmation email + */ +export class OrderProcessingWorkflow { + protected env: DevflareEnv + + constructor(env: DevflareEnv) { + this.env = env + } + + /** + * Main workflow execution + */ + async run( + event: WorkflowEvent, + step: WorkflowStep + ): Promise { + const { orderId, order: orderData } = event.params + const order = new Order(orderData) + + // Create workflow instance for tracking + const instance = new WorkflowInstance({ + id: `wf-${orderId}-${Date.now()}`, + workflowName: 'OrderProcessingWorkflow', + status: 'running', + currentStep: 'validate', + steps: [], + input: event.params, + startedAt: new Date().toISOString() + }) + + try { + // Step 1: Validate order + const validation = await step.do('validate-order', async () => { + const start = Date.now() + + // Validation logic + if (order.items.length === 0) { + throw new Error('Order has no items') + } + if (order.total <= 0) { + throw new Error('Order total must be positive') + } + if (!order.customerId) { + throw new Error('Customer ID is required') + } + + return { + valid: true, + total: order.total, + itemCount: order.itemCount, + duration: Date.now() - start + } + }) + + instance.addStep(new StepResult({ + stepName: 'validate-order', + success: true, + output: validation, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 2: Reserve inventory + instance.currentStep = 'reserve-inventory' + const inventory = await step.do('reserve-inventory', async () => { + // Simulate inventory reservation + const reserved = order.items.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + reserved: true + })) + + return { reserved, allAvailable: true } + }) + + instance.addStep(new StepResult({ + stepName: 'reserve-inventory', + success: true, + output: inventory, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 3: Process payment + instance.currentStep = 'process-payment' + const payment = await step.do('process-payment', async () => { + // Simulate payment processing + return { + transactionId: `txn-${Date.now()}`, + amount: order.total, + status: 'completed' + } + }) + + instance.addStep(new StepResult({ + stepName: 'process-payment', + success: true, + output: payment, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 4: Generate shipping label + instance.currentStep = 'generate-shipping' + const shipping = await step.do('generate-shipping-label', async () => { + // Simulate shipping label generation + const trackingNumber = `TRK${Date.now()}` + const label = `LABEL-${orderId}-${trackingNumber}` + + return { trackingNumber, shippingLabel: label } + }) + + instance.addStep(new StepResult({ + stepName: 'generate-shipping-label', + success: true, + output: shipping, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Step 5: Send confirmation (with small delay) + instance.currentStep = 'send-confirmation' + await step.sleep('confirmation-delay', '100ms') + + const confirmation = await step.do('send-confirmation', async () => { + // Simulate sending email + return { + emailSent: true, + recipient: order.customerId, + template: 'order-confirmation' + } + }) + + instance.addStep(new StepResult({ + stepName: 'send-confirmation', + success: true, + output: confirmation, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + })) + + // Update order status + order.updateStatus('shipped') + + // Complete workflow + const output: OrderProcessingOutput = { + orderId, + success: true, + shippingLabel: shipping.shippingLabel, + trackingNumber: shipping.trackingNumber + } + + instance.complete(output) + + // Store final state + await this.env.WORKFLOW_STATE.put( + `workflow:${instance.id}`, + JSON.stringify(instance.toData()) + ) + + return output + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + instance.fail(errorMessage) + + await this.env.WORKFLOW_STATE.put( + `workflow:${instance.id}`, + JSON.stringify(instance.toData()) + ) + + return { + orderId, + success: false, + error: errorMessage + } + } + } +} + +export default OrderProcessingWorkflow diff --git a/cases/case16/tests/workflow.test.ts b/cases/case16/tests/workflow.test.ts new file mode 100644 index 0000000..aa62881 --- /dev/null +++ b/cases/case16/tests/workflow.test.ts @@ -0,0 +1,496 @@ +// ============================================================================= +// Case 16: Workflows โ€” Tests +// ============================================================================= +// Tests for models and transport encoding/decoding. +// These are PURE LOGIC tests โ€” no mocks, no bindings, just testing the code. +// +// Workflow INTEGRATION tests are skipped since Workflows require deployment. +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext } from 'devflare/test' +import { env } from 'devflare' +import { + Order, + StepResult, + WorkflowInstance, + type OrderData, + type StepResultData, + type WorkflowInstanceData +} from '../src/models' +import { transport } from '../src/transport' +import { OrderProcessingWorkflow } from '../src/wf.order-processor' +import { DataPipelineWorkflow } from '../src/wf.data-pipeline' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +// ----------------------------------------------------------------------------- +// Order Model โ€” Pure Logic Tests +// ----------------------------------------------------------------------------- + +describe('Order Model', () => { + const orderData: OrderData = { + id: 'order-123', + customerId: 'cust-456', + items: [ + { productId: 'prod-1', name: 'Widget', quantity: 2, price: 10.00 }, + { productId: 'prod-2', name: 'Gadget', quantity: 1, price: 25.00 } + ], + status: 'pending', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z' + } + + test('creates Order from data', () => { + const order = new Order(orderData) + + expect(order.id).toBe('order-123') + expect(order.customerId).toBe('cust-456') + expect(order.items).toHaveLength(2) + expect(order.status).toBe('pending') + }) + + test('calculates total correctly', () => { + const order = new Order(orderData) + // 2 * 10 + 1 * 25 = 45 + expect(order.total).toBe(45) + }) + + test('calculates item count correctly', () => { + const order = new Order(orderData) + // 2 + 1 = 3 + expect(order.itemCount).toBe(3) + }) + + test('checks if cancellable', () => { + const pendingOrder = new Order({ ...orderData, status: 'pending' }) + const shippedOrder = new Order({ ...orderData, status: 'shipped' }) + + expect(pendingOrder.canCancel()).toBe(true) + expect(shippedOrder.canCancel()).toBe(false) + }) + + test('updates status', () => { + const order = new Order(orderData) + order.updateStatus('shipped') + + expect(order.status).toBe('shipped') + expect(order.updatedAt.getTime()).toBeGreaterThan(order.createdAt.getTime()) + }) + + test('converts to data', () => { + const order = new Order(orderData) + const data = order.toData() + + expect(data.id).toBe(orderData.id) + expect(data.customerId).toBe(orderData.customerId) + expect(data.items).toEqual(orderData.items) + }) +}) + +// ----------------------------------------------------------------------------- +// StepResult Model โ€” Pure Logic Tests +// ----------------------------------------------------------------------------- + +describe('StepResult Model', () => { + test('calculates duration', () => { + const result = new StepResult({ + stepName: 'test-step', + success: true, + startedAt: '2025-01-01T00:00:00.000Z', + completedAt: '2025-01-01T00:00:01.500Z', + retryCount: 0 + }) + + expect(result.durationMs).toBe(1500) + }) + + test('detects retries', () => { + const noRetry = new StepResult({ + stepName: 'test', + success: true, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 0 + }) + + const withRetry = new StepResult({ + stepName: 'test', + success: true, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + retryCount: 2 + }) + + expect(noRetry.hadRetries).toBe(false) + expect(withRetry.hadRetries).toBe(true) + }) +}) + +// ----------------------------------------------------------------------------- +// WorkflowInstance Model โ€” Pure Logic Tests +// ----------------------------------------------------------------------------- + +describe('WorkflowInstance Model', () => { + test('tracks successful and failed steps', () => { + const now = new Date().toISOString() + const instance = new WorkflowInstance({ + id: 'wf-1', + workflowName: 'TestWorkflow', + status: 'running', + currentStep: 'step3', + steps: [ + { stepName: 'step1', success: true, startedAt: now, completedAt: now, retryCount: 0 }, + { stepName: 'step2', success: true, startedAt: now, completedAt: now, retryCount: 0 }, + { stepName: 'step3', success: false, error: 'Failed', startedAt: now, completedAt: now, retryCount: 1 } + ], + input: {}, + startedAt: now + }) + + expect(instance.successfulSteps).toBe(2) + expect(instance.failedSteps).toBe(1) + }) + + test('completes workflow', () => { + const instance = new WorkflowInstance({ + id: 'wf-1', + workflowName: 'TestWorkflow', + status: 'running', + currentStep: 'final', + steps: [], + input: {}, + startedAt: new Date().toISOString() + }) + + instance.complete({ result: 'success' }) + + expect(instance.status).toBe('completed') + expect(instance.output).toEqual({ result: 'success' }) + expect(instance.completedAt).toBeDefined() + }) + + test('fails workflow', () => { + const instance = new WorkflowInstance({ + id: 'wf-1', + workflowName: 'TestWorkflow', + status: 'running', + currentStep: 'failing', + steps: [], + input: {}, + startedAt: new Date().toISOString() + }) + + instance.fail('Something went wrong') + + expect(instance.status).toBe('failed') + expect(instance.error).toBe('Something went wrong') + expect(instance.completedAt).toBeDefined() + }) +}) + +// ----------------------------------------------------------------------------- +// Transport Encoding/Decoding โ€” Pure Logic Tests +// ----------------------------------------------------------------------------- + +describe('Transport Encoding/Decoding', () => { + describe('Order Transport', () => { + const orderData: OrderData = { + id: 'order-789', + customerId: 'cust-123', + items: [{ productId: 'p1', name: 'Test', quantity: 1, price: 50 }], + status: 'pending', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z' + } + + test('encodes Order instance', () => { + const order = new Order(orderData) + const encoded = transport.Order.encode(order) + + expect(encoded).not.toBe(false) + expect((encoded as OrderData).id).toBe('order-789') + }) + + test('returns false for non-Order', () => { + const result = transport.Order.encode({ notAnOrder: true }) + expect(result).toBe(false) + }) + + test('decodes to Order instance', () => { + const decoded = transport.Order.decode(orderData) + + expect(decoded).toBeInstanceOf(Order) + expect(decoded.id).toBe('order-789') + expect(decoded.total).toBe(50) // Method works + }) + + test('roundtrips Order', () => { + const original = new Order(orderData) + const encoded = transport.Order.encode(original) + const decoded = transport.Order.decode(encoded as OrderData) + + expect(decoded.id).toBe(original.id) + expect(decoded.total).toBe(original.total) + expect(decoded.canCancel()).toBe(original.canCancel()) + }) + }) + + describe('StepResult Transport', () => { + test('encodes and decodes StepResult', () => { + const original = new StepResult({ + stepName: 'test-step', + success: true, + output: { data: 'test' }, + startedAt: '2025-01-01T00:00:00Z', + completedAt: '2025-01-01T00:00:01Z', + retryCount: 2 + }) + + const encoded = transport.StepResult.encode(original) + expect(encoded).not.toBe(false) + + const decoded = transport.StepResult.decode(encoded as StepResultData) + expect(decoded).toBeInstanceOf(StepResult) + expect(decoded.stepName).toBe('test-step') + expect(decoded.durationMs).toBe(1000) // Method works + expect(decoded.hadRetries).toBe(true) // Method works + }) + }) + + describe('WorkflowInstance Transport', () => { + test('encodes and decodes WorkflowInstance', () => { + const now = new Date().toISOString() + const original = new WorkflowInstance({ + id: 'wf-test', + workflowName: 'TestWorkflow', + status: 'completed', + currentStep: 'done', + steps: [ + { stepName: 's1', success: true, startedAt: now, completedAt: now, retryCount: 0 } + ], + input: { key: 'value' }, + output: { result: 'ok' }, + startedAt: '2025-01-01T00:00:00Z', + completedAt: '2025-01-01T00:00:05Z' + }) + + const encoded = transport.WorkflowInstance.encode(original) + expect(encoded).not.toBe(false) + + const decoded = transport.WorkflowInstance.decode(encoded as WorkflowInstanceData) + + expect(decoded).toBeInstanceOf(WorkflowInstance) + expect(decoded.id).toBe('wf-test') + expect(decoded.successfulSteps).toBe(1) // Method works + expect(decoded.durationMs).toBe(5000) // Method works + }) + }) +}) + +// ----------------------------------------------------------------------------- +// Workflow Logic Tests with Real KV +// ----------------------------------------------------------------------------- + +describe('OrderProcessingWorkflow with Real KV', () => { + // Step tracker that actually records calls + function createStepTracker() { + const stepsCalled: string[] = [] + return { + stepsCalled, + async do(name: string, fn: () => T | Promise): Promise { + stepsCalled.push(name) + return fn() + }, + async sleep(name: string, _duration: string): Promise { + stepsCalled.push(`sleep:${name}`) + }, + async sleepUntil(name: string, _timestamp: Date | string): Promise { + stepsCalled.push(`sleepUntil:${name}`) + }, + async waitForEvent(name: string, _options: { event: string; timeout: string }): Promise { + stepsCalled.push(`waitForEvent:${name}`) + return {} as T + } + } + } + + test('processes valid order successfully', async () => { + const workflow = new OrderProcessingWorkflow(env) + const step = createStepTracker() + + const result = await workflow.run( + { + params: { + orderId: 'order-test-1', + order: { + id: 'order-test-1', + customerId: 'cust-1', + items: [ + { productId: 'p1', name: 'Widget', quantity: 2, price: 10 } + ], + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + }, + timestamp: new Date() + }, + step + ) + + expect(result.success).toBe(true) + expect(result.orderId).toBe('order-test-1') + expect(result.trackingNumber).toBeDefined() + + // Verify workflow steps were executed + expect(step.stepsCalled).toContain('validate-order') + expect(step.stepsCalled).toContain('reserve-inventory') + expect(step.stepsCalled).toContain('process-payment') + }) + + test('fails for empty order', async () => { + const workflow = new OrderProcessingWorkflow(env) + const step = createStepTracker() + + const result = await workflow.run( + { + params: { + orderId: 'order-empty', + order: { + id: 'order-empty', + customerId: 'cust-1', + items: [], // Empty! + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + }, + timestamp: new Date() + }, + step + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('no items') + }) + + test('persists workflow state to real KV', async () => { + const workflow = new OrderProcessingWorkflow(env) + const step = createStepTracker() + + await workflow.run( + { + params: { + orderId: 'order-persist-test', + order: { + id: 'order-persist-test', + customerId: 'cust-1', + items: [{ productId: 'p1', name: 'Test', quantity: 1, price: 10 }], + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + }, + timestamp: new Date() + }, + step + ) + + // Check state was persisted in REAL KV + const list = await env.WORKFLOW_STATE.list({ prefix: 'workflow:' }) + expect(list.keys.length).toBeGreaterThan(0) + + const stateKey = list.keys.find((k) => k.name.includes('order-persist-test')) + expect(stateKey).toBeDefined() + + const state = JSON.parse(await env.WORKFLOW_STATE.get(stateKey!.name) ?? '{}') + expect(state.status).toBe('completed') + }) +}) + +describe('DataPipelineWorkflow with Real KV', () => { + function createStepTracker() { + const stepsCalled: string[] = [] + return { + stepsCalled, + async do(name: string, fn: () => T | Promise): Promise { + stepsCalled.push(name) + return fn() + }, + async sleep(name: string, _duration: string): Promise { + stepsCalled.push(`sleep:${name}`) + }, + async sleepUntil(name: string, _timestamp: Date | string): Promise { + stepsCalled.push(`sleepUntil:${name}`) + }, + async waitForEvent(name: string, _options: { event: string; timeout: string }): Promise { + stepsCalled.push(`waitForEvent:${name}`) + return {} as T + } + } + } + + test('processes data pipeline successfully', async () => { + const workflow = new DataPipelineWorkflow(env) + const step = createStepTracker() + + const result = await workflow.run( + { + params: { + sourceId: 'source-1', + sourcePath: '/data/input', + destinationPath: '/data/output', + transformations: ['normalize', 'filter'] + }, + timestamp: new Date() + }, + step + ) + + expect(result.recordsProcessed).toBeGreaterThan(0) + expect(result.outputPath).toBe('/data/output') + + // Verify steps were called + expect(step.stepsCalled).toContain('extract-data') + expect(step.stepsCalled).toContain('transform-normalize') + expect(step.stepsCalled).toContain('transform-filter') + expect(step.stepsCalled).toContain('validate-data') + expect(step.stepsCalled).toContain('load-data') + }) + + test('applies multiple transformations', async () => { + const workflow = new DataPipelineWorkflow(env) + const step = createStepTracker() + + const result = await workflow.run( + { + params: { + sourceId: 'source-2', + sourcePath: '/data/in', + destinationPath: '/data/out', + transformations: ['normalize', 'filter', 'enrich'] + }, + timestamp: new Date() + }, + step + ) + + expect(result.recordsProcessed).toBeGreaterThan(0) + + // All transformations should be called + expect(step.stepsCalled).toContain('transform-normalize') + expect(step.stepsCalled).toContain('transform-filter') + expect(step.stepsCalled).toContain('transform-enrich') + }) +}) diff --git a/cases/case16/tsconfig.json b/cases/case16/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case16/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case17/devflare.config.ts b/cases/case17/devflare.config.ts new file mode 100644 index 0000000..15dc9c8 --- /dev/null +++ b/cases/case17/devflare.config.ts @@ -0,0 +1,87 @@ +// ============================================================================= +// Case 17: Vite Plugin Namespace - Custom Config +// ============================================================================= +// Demonstrates the schema-level `vite` namespace and plugin-shaped metadata. +// This case is useful as a config/example reference, but it is not a proof that +// the main worker pipeline currently wires those plugins through end-to-end. +// ============================================================================= + +import { defineConfig } from 'devflare/config' +import type { Plugin } from 'vite' + +/** + * Custom plugin that adds build metadata + */ +function buildMetadataPlugin(): Plugin { + const buildTime = new Date().toISOString() + + return { + name: 'build-metadata', + transform(code, id) { + if (id.endsWith('.ts') && code.includes('__BUILD_TIME__')) { + return code.replace(/__BUILD_TIME__/g, JSON.stringify(buildTime)) + } + return null + } + } +} + +/** + * Custom plugin that adds environment info + */ +function envInfoPlugin(): Plugin { + return { + name: 'env-info', + transform(code, id) { + if (id.endsWith('.ts')) { + return code + .replace(/__ENV_MODE__/g, JSON.stringify(process.env.NODE_ENV ?? 'development')) + .replace(/__NODE_VERSION__/g, JSON.stringify(process.version)) + } + return null + } + } +} + +/** + * Custom plugin for virtual modules + */ +function virtualModulesPlugin(): Plugin { + const virtualModuleId = 'virtual:config' + const resolvedVirtualModuleId = '\0' + virtualModuleId + + return { + name: 'virtual-modules', + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId + } + return null + }, + load(id) { + if (id === resolvedVirtualModuleId) { + return ` + export const config = { + name: 'case17-rolldown-plugin', + version: '1.0.0', + features: ['custom-plugins', 'virtual-modules', 'transforms'] + } + ` + } + return null + } + } +} + +export default defineConfig({ + name: 'case17-rolldown-plugin', + compatibilityDate: '2026-04-26', + + vite: { + plugins: [ + buildMetadataPlugin(), + envInfoPlugin(), + virtualModulesPlugin() + ] + } +}) diff --git a/cases/case17/env.d.ts b/cases/case17/env.d.ts new file mode 100644 index 0000000..5c9c90d --- /dev/null +++ b/cases/case17/env.d.ts @@ -0,0 +1,13 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +declare global { + interface DevflareEnv { + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case17/package.json b/cases/case17/package.json new file mode 100644 index 0000000..7204e0e --- /dev/null +++ b/cases/case17/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devflare/case17-rolldown-plugin", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2", + "vite": "^6.4.0" + } +} \ No newline at end of file diff --git a/cases/case17/src/fetch.ts b/cases/case17/src/fetch.ts new file mode 100644 index 0000000..7f02522 --- /dev/null +++ b/cases/case17/src/fetch.ts @@ -0,0 +1,75 @@ +// ============================================================================= +// Case 17: Plugin Namespace Example - Fetch Handler +// ============================================================================= +// Demonstrates plugin-shaped placeholders and virtual-module usage in source. +// The case folder name is historical; this file should not be read as proof that the main +// worker pipeline already supports arbitrary Rolldown plugins end-to-end. +// ============================================================================= + +// Virtual module import (resolved by virtualModulesPlugin) +// @ts-expect-error - Virtual module resolved at build time +import { config } from 'virtual:config' + +// Build-time constants (replaced by plugins) +const BUILD_TIME = '__BUILD_TIME__' +const ENV_MODE = '__ENV_MODE__' +const NODE_VERSION = '__NODE_VERSION__' + +/** + * Main fetch handler + * Demonstrates plugin-shaped build-time placeholders and virtual modules + */ +export default async function fetch( + request: Request, + env: unknown, + ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json({ + name: 'Case 17: Plugin Namespace Example', + description: 'Demonstrates plugin-shaped metadata and virtual-module patterns' + }) + } + + // Route: GET /build-info + if (url.pathname === '/build-info') { + return Response.json({ + buildTime: BUILD_TIME, + envMode: ENV_MODE, + nodeVersion: NODE_VERSION + }) + } + + // Route: GET /config + if (url.pathname === '/config') { + return Response.json(config) + } + + // Route: GET /features + if (url.pathname === '/features') { + return Response.json({ + features: [ + { + name: 'Build Metadata Plugin', + description: 'Illustrates a build-time transform shape', + usage: '__BUILD_TIME__ is replaced with actual build time' + }, + { + name: 'Env Info Plugin', + description: 'Illustrates environment placeholder replacement', + usage: '__ENV_MODE__ and __NODE_VERSION__ placeholders' + }, + { + name: 'Virtual Modules Plugin', + description: 'Illustrates virtual module resolution patterns', + usage: "import { config } from 'virtual:config'" + } + ] + }) + } + + return Response.json({ error: 'Not found' }, { status: 404 }) +} diff --git a/cases/case17/tests/rolldown-plugin.test.ts b/cases/case17/tests/rolldown-plugin.test.ts new file mode 100644 index 0000000..b3bb17c --- /dev/null +++ b/cases/case17/tests/rolldown-plugin.test.ts @@ -0,0 +1,183 @@ +// ============================================================================= +// Case 17: Plugin Namespace Example - Tests +// ============================================================================= +// These are unit-style tests for plugin-shaped transform logic. They are not an +// end-to-end proof that the main worker pipeline currently wires those plugins. +// ============================================================================= + +import { describe, expect, it } from 'bun:test' + +/** + * Simulated transform function for testing + */ +type TransformFn = (code: string, id: string) => string | null + +/** + * Creates a build metadata transform + */ +function createBuildMetadataTransform(buildTime: string): TransformFn { + return (code: string, id: string) => { + if (id.endsWith('.ts') && code.includes('__BUILD_TIME__')) { + return code.replace(/__BUILD_TIME__/g, JSON.stringify(buildTime)) + } + return null + } +} + +/** + * Creates an env info transform + */ +function createEnvInfoTransform(env: { mode: string, nodeVersion: string }): TransformFn { + return (code: string, id: string) => { + if (id.endsWith('.ts')) { + return code + .replace(/__ENV_MODE__/g, JSON.stringify(env.mode)) + .replace(/__NODE_VERSION__/g, JSON.stringify(env.nodeVersion)) + } + return null + } +} + +/** + * Simulated virtual modules resolver for testing + */ +function createVirtualModulesResolver() { + const virtualModuleId = 'virtual:config' + const resolvedVirtualModuleId = '\0' + virtualModuleId + + return { + resolveId(id: string): string | null { + if (id === virtualModuleId) { + return resolvedVirtualModuleId + } + return null + }, + load(id: string): string | null { + if (id === resolvedVirtualModuleId) { + return `export const config = { name: 'test', version: '1.0.0' }` + } + return null + } + } +} + +describe('Case 17: Plugin Namespace Example', () => { + describe('Build Metadata Transform', () => { + it('replaces __BUILD_TIME__ placeholder', () => { + const buildTime = '2025-01-01T00:00:00.000Z' + const transform = createBuildMetadataTransform(buildTime) + + const code = 'const time = __BUILD_TIME__' + const result = transform(code, 'test.ts') + + expect(result).toBe(`const time = "${buildTime}"`) + }) + + it('handles multiple placeholders', () => { + const buildTime = '2025-01-01T00:00:00.000Z' + const transform = createBuildMetadataTransform(buildTime) + + const code = 'const a = __BUILD_TIME__; const b = __BUILD_TIME__;' + const result = transform(code, 'test.ts') + + expect(result).toBe(`const a = "${buildTime}"; const b = "${buildTime}";`) + }) + + it('returns null for non-ts files', () => { + const transform = createBuildMetadataTransform('test') + + const result = transform('const x = __BUILD_TIME__', 'test.js') + + expect(result).toBeNull() + }) + + it('returns null when no placeholder present', () => { + const transform = createBuildMetadataTransform('test') + + const result = transform('const x = 123', 'test.ts') + + expect(result).toBeNull() + }) + }) + + describe('Env Info Transform', () => { + it('replaces environment placeholders', () => { + const transform = createEnvInfoTransform({ + mode: 'production', + nodeVersion: 'v20.0.0' + }) + + const code = 'const mode = __ENV_MODE__; const node = __NODE_VERSION__;' + const result = transform(code, 'test.ts') + + expect(result).toBe('const mode = "production"; const node = "v20.0.0";') + }) + + it('handles missing placeholders', () => { + const transform = createEnvInfoTransform({ + mode: 'development', + nodeVersion: 'v18.0.0' + }) + + const code = 'const x = 123' + const result = transform(code, 'test.ts') + + expect(result).toBe('const x = 123') + }) + }) + + describe('Virtual Modules Resolver', () => { + it('resolves virtual module id', () => { + const resolver = createVirtualModulesResolver() + + const result = resolver.resolveId('virtual:config') + + expect(result).toBe('\0virtual:config') + }) + + it('returns null for non-virtual imports', () => { + const resolver = createVirtualModulesResolver() + + const result = resolver.resolveId('./some-module') + + expect(result).toBeNull() + }) + + it('loads virtual module content', () => { + const resolver = createVirtualModulesResolver() + + const result = resolver.load('\0virtual:config') + + expect(result).toContain("export const config") + expect(result).toContain("name: 'test'") + }) + + it('returns null for non-virtual module loads', () => { + const resolver = createVirtualModulesResolver() + + const result = resolver.load('./some-module') + + expect(result).toBeNull() + }) + }) + + describe('Transform Composition', () => { + it('transforms can be chained', () => { + const transforms = [ + createBuildMetadataTransform('2025-01-01'), + createEnvInfoTransform({ mode: 'prod', nodeVersion: 'v20' }) + ] + + let code = 'const t = __BUILD_TIME__; const m = __ENV_MODE__;' + + for (const transform of transforms) { + const result = transform(code, 'test.ts') + if (typeof result === 'string') { + code = result + } + } + + expect(code).toBe('const t = "2025-01-01"; const m = "prod";') + }) + }) +}) diff --git a/cases/case17/tsconfig.json b/cases/case17/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case17/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case18/.gitignore b/cases/case18/.gitignore new file mode 100644 index 0000000..b31a89b --- /dev/null +++ b/cases/case18/.gitignore @@ -0,0 +1,9 @@ +# Devflare generated config (gitignored by default) +.devflare/ + +# Build output +.svelte-kit/ +.wrangler/ + +# Dependencies +node_modules/ diff --git a/cases/case18/api/src/ep.api.ts b/cases/case18/api/src/ep.api.ts new file mode 100644 index 0000000..0431886 --- /dev/null +++ b/cases/case18/api/src/ep.api.ts @@ -0,0 +1,30 @@ +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class Case18Api extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url) + const env = this.env as { API_PREFIX?: string } + + if (request.method === 'POST' && url.pathname === '/auth/magic-link/request') { + const body = await request.json() as { email?: string } + return Response.json({ + ok: true, + email: body.email, + prefix: env.API_PREFIX + }, { + headers: { + 'x-case18-api': 'service-fetch' + } + }) + } + + if (url.pathname === '/bootstrap') { + return Response.json({ + ok: true, + prefix: env.API_PREFIX + }) + } + + return new Response('not found', { status: 404 }) + } +} diff --git a/cases/case18/devflare.config.ts b/cases/case18/devflare.config.ts new file mode 100644 index 0000000..fce2562 --- /dev/null +++ b/cases/case18/devflare.config.ts @@ -0,0 +1,138 @@ +// ============================================================================= +// Case 18: Full SvelteKit Application - Devflare Configuration +// ============================================================================= +// Comprehensive example demonstrating ALL major Cloudflare bindings: +// - R2 bucket for image storage +// - Durable Objects for realtime chat and PDF rendering +// - KV namespace for key-value storage +// - D1 database for relational data +// +// โœ… ARCHITECTURE: +// - DOs are auto-discovered via `files.durableObjects` glob pattern +// - devflare compiles everything to .devflare/wrangler.jsonc +// - `devflare dev --vite` runs Vite + auxiliaryWorkers for DOs +// - No manual worker files needed! +// ============================================================================= + +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case18-sveltekit-full', + // compatibilityDate is optional - defaults to current date + // compatibilityFlags is optional - nodejs_compat and nodejs_als are always forced + + // File-based conventions + files: { + // SvelteKit writes the Worker entry during build/dev, so Devflare should not compose it. + fetch: false, + // Auto-discover DO classes from src/do.*.ts files + durableObjects: 'src/do.*.ts', + // Auto-discover Workflow classes from src/wf.*.ts files + workflows: 'src/wf.*.ts', + // Transport for RPC serialization (SvelteKit signature) + transport: 'src/transport.ts' + }, + + secretsStoreId: 'case18-local-store', + + bindings: { + // R2 bucket for image uploads + r2: { + IMAGES: 'images-bucket' + }, + + // KV namespace for key-value storage + kv: { + CACHE: 'cache-kv' + }, + + // D1 database for relational data + d1: { + DB: 'main-db' + }, + + // Durable Objects - className only, no scriptName needed + // The classes are auto-discovered from files.durableObjects + durableObjects: { + CHAT_ROOM: { + className: 'ChatRoom' + }, + PDF_RENDERER: { + className: 'PdfRenderer' + } + }, + + // Browser Rendering for PDF generation + browser: { + binding: 'BROWSER' + }, + + // Hyperdrive local connection details for database-client code paths + hyperdrive: { + POSTGRES: { + id: 'case18-hyperdrive', + localConnectionString: 'postgres://case18:password@localhost:5432/case18' + } + }, + + // Dynamic Worker loading from explicit code payloads + workerLoaders: { + WORKER_LOADER: {} + }, + + // Workflow binding implemented by src/wf.order.ts + workflows: { + ORDER_WORKFLOW: { + name: 'case18-order-workflow', + className: 'OrderWorkflow' + } + }, + + // Images and Media Transformations local shims + images: { + IMAGES_SERVICE: true + }, + media: { + MEDIA_SERVICE: true + }, + + // Secrets Store local values live in .devflare/secrets.local.json + secretsStore: { + API_TOKEN: 'api-token' + }, + + // Send Email local binding + sendEmail: { + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + }, + + // Migrations for Durable Objects + migrations: [ + { + tag: 'v1', + new_classes: ['ChatRoom', 'PdfRenderer'] + } + ], + + // WebSocket routes for dev mode DO proxying + // These bypass SvelteKit and go directly to DOs for WebSocket connections + wsRoutes: [ + { + pattern: '/chat/api', + doNamespace: 'CHAT_ROOM', + idParam: 'roomId', + forwardPath: '/websocket' + } + ], + + wrangler: { + passthrough: { + main: '.svelte-kit/cloudflare/_worker.js' + } + } +}) + diff --git a/cases/case18/devflare.local-bindings.config.ts b/cases/case18/devflare.local-bindings.config.ts new file mode 100644 index 0000000..e78acd9 --- /dev/null +++ b/cases/case18/devflare.local-bindings.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, ref } from 'devflare/config' + +const apiWorker = ref('case18-service-api', () => import('./devflare.service-api.config')) + +export default defineConfig({ + name: 'case18-sveltekit-local-bindings', + compatibilityDate: '2026-04-27', + files: { + fetch: false, + workflows: 'src/wf.*.ts', + transport: 'src/transport.ts' + }, + vars: { + CASE18_STRING_VAR: 'case18-var-value' + }, + secretsStoreId: 'case18-local-store', + bindings: { + services: { + CASE18_API: apiWorker.worker('Case18Api') + }, + hyperdrive: { + POSTGRES: { + id: 'case18-hyperdrive', + localConnectionString: 'postgres://case18:password@localhost:5432/case18' + } + }, + workerLoaders: { + WORKER_LOADER: {} + }, + workflows: { + ORDER_WORKFLOW: { + name: 'case18-order-workflow', + className: 'OrderWorkflow' + } + }, + images: { + IMAGES_SERVICE: true + }, + media: { + MEDIA_SERVICE: true + }, + secretsStore: { + API_TOKEN: 'api-token' + }, + sendEmail: { + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + }, + wrangler: { + passthrough: { + main: '.svelte-kit/cloudflare/_worker.js' + } + } +}) diff --git a/cases/case18/devflare.service-api.config.ts b/cases/case18/devflare.service-api.config.ts new file mode 100644 index 0000000..0a5b9b2 --- /dev/null +++ b/cases/case18/devflare.service-api.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case18-service-api', + compatibilityDate: '2026-04-27', + files: { + fetch: false, + entrypoints: 'api/src/ep.*.ts' + }, + vars: { + API_PREFIX: 'case18-api' + } +}) diff --git a/cases/case18/env.d.ts b/cases/case18/env.d.ts new file mode 100644 index 0000000..708c923 --- /dev/null +++ b/cases/case18/env.d.ts @@ -0,0 +1,56 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { + D1Database, + DurableObjectNamespace, + Fetcher, + Hyperdrive, + ImagesBinding, + KVNamespace, + MediaBinding, + R2Bucket, + Rpc, + SecretsStoreSecret, + SendEmail, + WorkerLoader, + Workflow +} from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + CACHE: KVNamespace + DB: D1Database + IMAGES: R2Bucket + CHAT_ROOM: DurableObjectNamespace + PDF_RENDERER: DurableObjectNamespace + BROWSER: Fetcher + POSTGRES: Hyperdrive + WORKER_LOADER: WorkerLoader + ORDER_WORKFLOW: Workflow<{ orderId: string; total: number }> + IMAGES_SERVICE: ImagesBinding + MEDIA_SERVICE: MediaBinding + API_TOKEN: SecretsStoreSecret + EMAIL: SendEmail + } +} + +declare module 'devflare/test' { + interface DevflareEnv { + CACHE: KVNamespace + DB: D1Database + IMAGES: R2Bucket + CHAT_ROOM: DurableObjectNamespace + PDF_RENDERER: DurableObjectNamespace + BROWSER: Fetcher + POSTGRES: Hyperdrive + WORKER_LOADER: WorkerLoader + ORDER_WORKFLOW: Workflow<{ orderId: string; total: number }> + IMAGES_SERVICE: ImagesBinding + MEDIA_SERVICE: MediaBinding + API_TOKEN: SecretsStoreSecret + EMAIL: SendEmail + } +} + +export {} diff --git a/cases/case18/migrations/0001_create_todos.sql b/cases/case18/migrations/0001_create_todos.sql new file mode 100644 index 0000000..4c345d4 --- /dev/null +++ b/cases/case18/migrations/0001_create_todos.sql @@ -0,0 +1,10 @@ +-- Migration 0001: Create todos table +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + completed BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for listing +CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at DESC); diff --git a/cases/case18/package.json b/cases/case18/package.json new file mode 100644 index 0000000..b34d906 --- /dev/null +++ b/cases/case18/package.json @@ -0,0 +1,30 @@ +{ + "name": "@devflare/case18-sveltekit-full", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "devflare dev", + "dev:full": "devflare dev --full", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@cloudflare/puppeteer": "^1.0.4" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260317.1", + "@sveltejs/adapter-cloudflare": "^6.0.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/cases/case18/src/app.d.ts b/cases/case18/src/app.d.ts new file mode 100644 index 0000000..aa0e99a --- /dev/null +++ b/cases/case18/src/app.d.ts @@ -0,0 +1,31 @@ +// ============================================================================= +// Case 18: SvelteKit App Type Definitions +// ============================================================================= + +// DevflareEnv is globally declared by env.d.ts (generated by `devflare types`) + +declare global { + namespace App { + interface Platform { + env: DevflareEnv + context: { + waitUntil(promise: Promise): void + } + } + + interface Locals { + // Add any request-local data here + } + + interface PageData { + // Add any shared page data here + } + + interface Error { + message: string + code?: string + } + } +} + +export { } diff --git a/cases/case18/src/app.html b/cases/case18/src/app.html new file mode 100644 index 0000000..6769ed5 --- /dev/null +++ b/cases/case18/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/cases/case18/src/do.chat-room.ts b/cases/case18/src/do.chat-room.ts new file mode 100644 index 0000000..46ebc3c --- /dev/null +++ b/cases/case18/src/do.chat-room.ts @@ -0,0 +1,346 @@ +// ============================================================================= +// Case 18: ChatRoom Durable Object +// ============================================================================= +// WebSocket-based chat room with hibernation support for cost savings. +// Demonstrates: +// - WebSocket hibernation API +// - serializeAttachment/deserializeAttachment for state persistence +// - Multi-client coordination +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' +import type { DurableObjectNamespace } from '@cloudflare/workers-types' +import { ChatMessage, UserPresence, type ChatMessageData, type UserPresenceData } from '$lib/models' + +interface WebSocketData { + userId: string + username: string + joinedAt: number +} + +interface StoredMessage { + id: string + userId: string + username: string + content: string + timestamp: number +} + +interface Env { + CHAT_ROOM: DurableObjectNamespace +} + +export class ChatRoom extends DurableObject { + private roomId: string = '' + + /** + * Handle HTTP requests (including WebSocket upgrades) + */ + async fetch(request: Request): Promise { + const url = new URL(request.url) + + // WebSocket upgrade request + if (request.headers.get('Upgrade') === 'websocket') { + return this.handleWebSocketUpgrade(request, url) + } + + // Get room info + if (url.pathname === '/info') { + return this.handleGetInfo() + } + + // Get message history + if (url.pathname === '/history') { + return this.handleGetHistory() + } + + // Get online users + if (url.pathname === '/users') { + return this.handleGetUsers() + } + + return new Response('Not found', { status: 404 }) + } + + /** + * Handle WebSocket upgrade + */ + private async handleWebSocketUpgrade( + request: Request, + url: URL + ): Promise { + const username = url.searchParams.get('username') + const userId = url.searchParams.get('userId') || crypto.randomUUID() + + if (!username) { + return new Response('Missing username parameter', { status: 400 }) + } + + this.roomId = url.searchParams.get('roomId') || 'default' + + // Create WebSocket pair + const pair = new WebSocketPair() + const [client, server] = Object.values(pair) + + // Accept with hibernation support + this.ctx.acceptWebSocket(server) + + // Attach user data for hibernation persistence + const wsData: WebSocketData = { + userId, + username, + joinedAt: Date.now() + } + server.serializeAttachment(wsData) + + // Broadcast join message + const joinMessage = new ChatMessage({ + userId: 'system', + username: 'System', + content: `${username} joined the chat`, + roomId: this.roomId + }) + this.broadcast(JSON.stringify({ + type: 'message', + data: this.serializeMessage(joinMessage) + }), server) + + // Send welcome message to new user + const welcomeMessage = { + type: 'welcome', + userId, + roomId: this.roomId, + onlineCount: this.ctx.getWebSockets().length + } + server.send(JSON.stringify(welcomeMessage)) + + return new Response(null, { status: 101, webSocket: client }) + } + + /** + * Handle incoming WebSocket message (hibernation API) + */ + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + if (typeof message !== 'string') { + ws.send(JSON.stringify({ error: 'Binary messages not supported' })) + return + } + + const data = ws.deserializeAttachment() as WebSocketData + if (!data) { + ws.send(JSON.stringify({ error: 'Session not found' })) + return + } + + try { + const parsed = JSON.parse(message) + + if (parsed.type === 'message') { + // Create and store chat message + const chatMessage = new ChatMessage({ + userId: data.userId, + username: data.username, + content: parsed.content, + roomId: this.roomId + }) + + // Store in durable storage (keep last 100 messages) + await this.storeMessage(chatMessage) + + // Broadcast to all clients + const outgoing = { + type: 'message', + data: this.serializeMessage(chatMessage) + } + this.broadcast(JSON.stringify(outgoing)) + } else if (parsed.type === 'typing') { + // Broadcast typing indicator + const typing = { + type: 'typing', + userId: data.userId, + username: data.username + } + this.broadcast(JSON.stringify(typing), ws) + } + } catch { + ws.send(JSON.stringify({ error: 'Invalid message format' })) + } + } + + /** + * Handle WebSocket close (hibernation API) + */ + async webSocketClose(ws: WebSocket, code: number, reason: string) { + const data = ws.deserializeAttachment() as WebSocketData + if (!data) return + + // Broadcast leave message + const leaveMessage = new ChatMessage({ + userId: 'system', + username: 'System', + content: `${data.username} left the chat`, + roomId: this.roomId + }) + this.broadcast(JSON.stringify({ + type: 'message', + data: this.serializeMessage(leaveMessage) + })) + } + + /** + * Handle WebSocket error + */ + async webSocketError(ws: WebSocket, error: unknown) { + const data = ws.deserializeAttachment() as WebSocketData + console.error(`WebSocket error for user ${data?.username}:`, error) + } + + /** + * Broadcast message to all connected WebSockets + */ + private broadcast(message: string, exclude?: WebSocket) { + const sockets = this.ctx.getWebSockets() + for (const socket of sockets) { + if (socket !== exclude && socket.readyState === WebSocket.OPEN) { + socket.send(message) + } + } + } + + /** + * Serialize ChatMessage for WebSocket transmission + */ + private serializeMessage(msg: ChatMessage): ChatMessageData { + return { + id: msg.id, + userId: msg.userId, + username: msg.username, + content: msg.content, + timestamp: msg.timestamp, + roomId: msg.roomId + } + } + + /** + * Serialize UserPresence for WebSocket transmission + */ + private serializePresence(user: UserPresence): UserPresenceData { + return { + id: user.id, + username: user.username, + status: user.status, + lastSeen: user.lastSeen + } + } + + /** + * Store message in durable storage + */ + private async storeMessage(message: ChatMessage): Promise { + const key = `msg:${message.timestamp}:${message.id}` + const stored: StoredMessage = { + id: message.id, + userId: message.userId, + username: message.username, + content: message.content, + timestamp: message.timestamp + } + await this.ctx.storage.put(key, stored) + + // Cleanup old messages (keep last 100) + const messages = await this.ctx.storage.list({ prefix: 'msg:' }) + if (messages.size > 100) { + const sortedKeys = [...messages.keys()].sort() + const toDelete = sortedKeys.slice(0, messages.size - 100) + await this.ctx.storage.delete(toDelete) + } + } + + /** + * Get room info + */ + private async handleGetInfo(): Promise { + const messageCount = (await this.ctx.storage.list({ prefix: 'msg:' })).size + const onlineCount = this.ctx.getWebSockets().length + + return Response.json({ + roomId: this.roomId, + messageCount, + onlineCount + }) + } + + /** + * Get message history + */ + private async handleGetHistory(): Promise { + const messages = await this.ctx.storage.list({ + prefix: 'msg:', + limit: 50 + }) + + const history: ChatMessageData[] = [...messages.values()].map((msg) => ({ + id: msg.id, + userId: msg.userId, + username: msg.username, + content: msg.content, + timestamp: msg.timestamp, + roomId: this.roomId + })) + + return Response.json({ messages: history }) + } + + /** + * Get online users + */ + private handleGetUsers(): Response { + const sockets = this.ctx.getWebSockets() + const users: UserPresenceData[] = [] + + for (const socket of sockets) { + const data = socket.deserializeAttachment() as WebSocketData | null + if (data) { + const presence = new UserPresence({ + id: data.userId, + username: data.username, + status: 'online', + lastSeen: Date.now() + }) + users.push(this.serializePresence(presence)) + } + } + + return Response.json({ users }) + } + + // ========================================================================== + // RPC Methods (direct method invocation) + // ========================================================================== + + /** + * RPC: Get online user count + */ + getOnlineCount(): number { + return this.ctx.getWebSockets().length + } + + /** + * RPC: Broadcast a system message + */ + async broadcastSystemMessage(content: string): Promise { + const message = new ChatMessage({ + userId: 'system', + username: 'System', + content, + roomId: this.roomId + }) + await this.storeMessage(message) + this.broadcast( + JSON.stringify({ + type: 'message', + data: this.serializeMessage(message) + }) + ) + } +} diff --git a/cases/case18/src/do.pdf-renderer.ts b/cases/case18/src/do.pdf-renderer.ts new file mode 100644 index 0000000..02f5b35 --- /dev/null +++ b/cases/case18/src/do.pdf-renderer.ts @@ -0,0 +1,420 @@ +// ============================================================================= +// Case 18: PdfRenderer Durable Object +// ============================================================================= +// Browser Rendering-based PDF generator running inside a Durable Object. +// Demonstrates: +// - Browser Rendering binding usage +// - Puppeteer API for PDF generation +// - DO-based caching of rendered PDFs +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' +import type { DurableObjectNamespace, Fetcher } from '@cloudflare/workers-types' +import { PdfRequest, PdfResult, type PdfRequestData, type PdfResultData } from '$lib/models' + +interface CachedPdf { + pdfBase64: string + generatedAt: number + url: string +} + +interface Env { + BROWSER: Fetcher + PDF_RENDERER: DurableObjectNamespace +} + +export class PdfRenderer extends DurableObject { + + /** + * Handle HTTP requests + */ + async fetch(request: Request): Promise { + try { + const url = new URL(request.url) + + // Generate PDF + if (url.pathname === '/generate' && request.method === 'POST') { + return this.handleGenerate(request) + } + + // Get cached PDF + if (url.pathname.startsWith('/cached/')) { + const id = url.pathname.slice(8) + return this.handleGetCached(id) + } + + // Get renderer stats + if (url.pathname === '/stats') { + return this.handleGetStats() + } + + // Clear cache + if (url.pathname === '/clear-cache' && request.method === 'POST') { + return this.handleClearCache() + } + + return new Response('Not found', { status: 404 }) + } catch (error) { + console.error('[PdfRenderer] fetch error:', error) + throw error + } + } + + /** + * Handle PDF generation request + */ + private async handleGenerate(request: Request): Promise { + const start = Date.now() + + try { + const body = await request.json() as PdfRequestData + const pdfRequest = new PdfRequest(body) + + // Check cache first + const cacheKey = `pdf:${this.hashUrl(pdfRequest.url)}` + const cached = await this.ctx.storage.get(cacheKey) + + if (cached && Date.now() - cached.generatedAt < 300000) { + // Return cached if less than 5 minutes old + // Use chunked decode to avoid memory issues with large PDFs + const pdfBytes = this.base64ToUint8Array(cached.pdfBase64) + // Cast to ArrayBuffer for Response body compatibility + const bodyBuffer = pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) as ArrayBuffer + + return new Response(bodyBuffer, { + headers: { + 'Content-Type': 'application/pdf', + 'X-Request-Id': pdfRequest.id, + 'X-Cached': 'true', + 'X-Duration-Ms': String(Date.now() - start) + } + }) + } + + // Generate new PDF + const pdfData = await this.generatePdf(pdfRequest) + + // Cache the result - use chunked encoding to avoid stack overflow for large PDFs + const pdfBase64 = this.uint8ArrayToBase64(pdfData) + await this.ctx.storage.put(cacheKey, { + pdfBase64, + generatedAt: Date.now(), + url: pdfRequest.url + }) + + // Track stats + await this.incrementStat('generated') + + // Cast to ArrayBuffer for Response body compatibility + const bodyBuffer = pdfData.buffer.slice(pdfData.byteOffset, pdfData.byteOffset + pdfData.byteLength) as ArrayBuffer + + return new Response(bodyBuffer, { + headers: { + 'Content-Type': 'application/pdf', + 'X-Request-Id': pdfRequest.id, + 'X-Cached': 'false', + 'X-Duration-Ms': String(Date.now() - start) + } + }) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + await this.incrementStat('errors') + + const result = new PdfResult({ + requestId: 'unknown', + success: false, + error: errorMessage, + durationMs: Date.now() - start + }) + + return Response.json(this.serializeResult(result), { status: 500 }) + } + } + + /** + * Generate PDF using Puppeteer with retry logic + */ + private async generatePdf(request: PdfRequest): Promise { + const MAX_RETRIES = 3 + const BASE_DELAY_MS = 500 + let lastError: Error | null = null + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await this.attemptGeneratePdf(request) + if (attempt > 1) { + console.log(`[PdfRenderer] Recovered on attempt ${attempt} for: ${request.url}`) + } + return result + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + const errorMsg = lastError.message + + // Check if error is retryable (connection/protocol errors) + const isRetryable = errorMsg.includes('Protocol error') || + errorMsg.includes('Target closed') || + errorMsg.includes('Connection closed') || + errorMsg.includes('Network connection lost') || + errorMsg.includes('Session closed') || + errorMsg.includes('Navigation timeout') + + console.warn(`[PdfRenderer] Attempt ${attempt}/${MAX_RETRIES} failed for ${request.url}: ${errorMsg}`) + + if (!isRetryable || attempt >= MAX_RETRIES) { + break + } + + // Exponential backoff: 500ms, 1000ms, 2000ms... + const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + + throw lastError ?? new Error('PDF generation failed after retries') + } + + /** + * Single attempt to generate PDF + * Note: Scripts are blocked for stability on complex sites. + * This means JS-rendered content will not appear in the PDF. + */ + private async attemptGeneratePdf(request: PdfRequest): Promise { + const { default: puppeteer } = await import('@cloudflare/puppeteer') + // Launch browser via Browser Rendering binding + const browser = await puppeteer.launch(this.env.BROWSER as unknown as Parameters[0]) + + let page: Awaited> | null = null + + try { + page = await browser.newPage() + + // Set a reasonable viewport + await page.setViewport({ width: 1280, height: 720 }) + + // Block heavy resources to speed up PDF generation and reduce memory + // Note: This means JS-rendered/SPA content will NOT appear in the PDF + await page.setRequestInterception(true) + page.on('request', (req) => { + const resourceType = req.resourceType() + // Block images, media, fonts, scripts to reduce load significantly + if (['image', 'media', 'font', 'script'].includes(resourceType)) { + req.abort() + } else { + req.continue() + } + }) + + // Navigate to URL with 'load' wait condition for reasonable render + await page.goto(request.url, { + waitUntil: 'load', + timeout: 45000 + }) + + // Give the page a moment to render after load + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Generate PDF with longer timeout for complex pages + const pdfBuffer = await page.pdf({ + format: request.options.format, + landscape: request.options.landscape, + printBackground: request.options.printBackground, + margin: request.options.margin, + timeout: 90000 // 90 second timeout for PDF generation + }) + + return new Uint8Array(pdfBuffer) + } finally { + // Close page first to release resources + if (page) { + try { + await page.close() + } catch { + // Ignore page close errors + } + } + + // Gracefully close browser - ignore errors if already closed + try { + await browser.close() + } catch { + // Expected when browser already closed due to crash/timeout + } + } + } + + /** + * Get cached PDF by ID + */ + private async handleGetCached(id: string): Promise { + const cacheKey = `pdf:${id}` + const cached = await this.ctx.storage.get(cacheKey) + + if (!cached) { + return new Response('PDF not found', { status: 404 }) + } + + const pdfData = new Uint8Array( + [...atob(cached.pdfBase64)].map((c) => c.charCodeAt(0)) + ) + // Cast to ArrayBuffer for Response body compatibility + const bodyBuffer = pdfData.buffer.slice(pdfData.byteOffset, pdfData.byteOffset + pdfData.byteLength) as ArrayBuffer + + return new Response(bodyBuffer, { + headers: { + 'Content-Type': 'application/pdf', + 'X-Generated-At': new Date(cached.generatedAt).toISOString(), + 'X-Source-Url': cached.url + } + }) + } + + /** + * Get renderer statistics + */ + private async handleGetStats(): Promise { + const generated = (await this.ctx.storage.get('stat:generated')) ?? 0 + const errors = (await this.ctx.storage.get('stat:errors')) ?? 0 + const cacheEntries = await this.ctx.storage.list({ prefix: 'pdf:' }) + + return Response.json({ + generated, + errors, + cacheSize: cacheEntries.size, + successRate: generated > 0 ? ((generated - errors) / generated) * 100 : 0 + }) + } + + /** + * Clear the cache + */ + private async handleClearCache(): Promise { + const entries = await this.ctx.storage.list({ prefix: 'pdf:' }) + const keys = [...entries.keys()] + await this.ctx.storage.delete(keys) + + return Response.json({ cleared: keys.length }) + } + + /** + * Increment a stat counter + */ + private async incrementStat(stat: string): Promise { + const key = `stat:${stat}` + const current = (await this.ctx.storage.get(key)) ?? 0 + await this.ctx.storage.put(key, current + 1) + } + + /** + * Hash URL for cache key + */ + private hashUrl(url: string): string { + let hash = 0 + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(36) + } + + /** + * Serialize PdfResult for HTTP responses + */ + private serializeResult(result: PdfResult): PdfResultData { + return { + requestId: result.requestId, + success: result.success, + pdfBase64: result.pdfBase64, + error: result.error, + generatedAt: result.generatedAt, + durationMs: result.durationMs + } + } + + /** + * Convert Uint8Array to Base64 string without stack overflow + * Uses chunked approach to handle large arrays + */ + private uint8ArrayToBase64(data: Uint8Array): string { + const CHUNK_SIZE = 0x8000 // 32KB chunks to avoid stack overflow + const chunks: string[] = [] + + for (let i = 0; i < data.length; i += CHUNK_SIZE) { + const chunk = data.subarray(i, Math.min(i + CHUNK_SIZE, data.length)) + chunks.push(String.fromCharCode(...chunk)) + } + + return btoa(chunks.join('')) + } + + /** + * Convert Base64 string to Uint8Array without memory issues + * Uses streaming approach for large PDFs + */ + private base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64) + const length = binaryString.length + const result = new Uint8Array(length) + + // Process in chunks to avoid creating large intermediate arrays + for (let i = 0; i < length; i++) { + result[i] = binaryString.charCodeAt(i) + } + + return result + } + + // ========================================================================== + // RPC Methods + // ========================================================================== + + /** + * RPC: Generate PDF and return result + * Transport encoding/decoding is handled automatically by devflare + */ + async generatePdfRpc(requestDto: PdfRequestData): Promise { + const start = Date.now() + const request = new PdfRequest(requestDto) + + try { + const pdfData = await this.generatePdf(request) + const pdfBase64 = this.uint8ArrayToBase64(pdfData) + + return new PdfResult({ + requestId: request.id, + success: true, + pdfBase64, + durationMs: Date.now() - start + }) + } catch (error) { + return new PdfResult({ + requestId: request.id, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + durationMs: Date.now() - start + }) + } + } + + /** + * RPC: Get statistics + */ + async getStats(): Promise<{ + generated: number + errors: number + cacheSize: number + }> { + const generated = (await this.ctx.storage.get('stat:generated')) ?? 0 + const errors = (await this.ctx.storage.get('stat:errors')) ?? 0 + const cacheEntries = await this.ctx.storage.list({ prefix: 'pdf:' }) + + return { + generated, + errors, + cacheSize: cacheEntries.size + } + } +} + diff --git a/cases/case18/src/hooks.server.ts b/cases/case18/src/hooks.server.ts new file mode 100644 index 0000000..967bde7 --- /dev/null +++ b/cases/case18/src/hooks.server.ts @@ -0,0 +1,18 @@ +// ============================================================================= +// SvelteKit Hooks โ€” Server-side request handling +// ============================================================================= +// In development mode with devflare, use the pre-configured handle that +// auto-loads binding hints from devflare.config.ts. +// ============================================================================= + +import { sequence } from '@sveltejs/kit/hooks' +import { handle as devflareHandle } from 'devflare/sveltekit' + +// Devflare handle must be first to set up platform.env +// Add your own hooks after it in the sequence +export const handle = sequence( + devflareHandle + // Add your own handles here, e.g.: + // authHandle, + // loggingHandle, +) diff --git a/cases/case18/src/lib/models/chat-message.ts b/cases/case18/src/lib/models/chat-message.ts new file mode 100644 index 0000000..d7f1c66 --- /dev/null +++ b/cases/case18/src/lib/models/chat-message.ts @@ -0,0 +1,40 @@ +// ============================================================================= +// ChatMessage โ€” Transportable class for chat messages +// ============================================================================= + +export interface ChatMessageData { + id?: string + userId: string + username: string + content: string + timestamp?: number + roomId: string +} + +export class ChatMessage { + readonly id: string + readonly userId: string + readonly username: string + readonly content: string + readonly timestamp: number + readonly roomId: string + + constructor(data: ChatMessageData) { + this.id = data.id ?? crypto.randomUUID() + this.userId = data.userId + this.username = data.username + this.content = data.content + this.timestamp = data.timestamp ?? Date.now() + this.roomId = data.roomId + } + + /** Check if message is from system */ + get isSystem(): boolean { + return this.userId === 'system' + } + + /** Format timestamp as readable string */ + get formattedTime(): string { + return new Date(this.timestamp).toLocaleTimeString() + } +} diff --git a/cases/case18/src/lib/models/index.ts b/cases/case18/src/lib/models/index.ts new file mode 100644 index 0000000..ff3159b --- /dev/null +++ b/cases/case18/src/lib/models/index.ts @@ -0,0 +1,8 @@ +// ============================================================================= +// Models โ€” Re-export all transportable classes +// ============================================================================= + +export { ChatMessage, type ChatMessageData } from './chat-message' +export { UserPresence, type UserPresenceData } from './user-presence' +export { PdfRequest, type PdfRequestData, type PdfOptions } from './pdf-request' +export { PdfResult, type PdfResultData } from './pdf-result' diff --git a/cases/case18/src/lib/models/pdf-request.ts b/cases/case18/src/lib/models/pdf-request.ts new file mode 100644 index 0000000..ccc1c02 --- /dev/null +++ b/cases/case18/src/lib/models/pdf-request.ts @@ -0,0 +1,46 @@ +// ============================================================================= +// PdfRequest โ€” Transportable class for PDF generation requests +// ============================================================================= + +export interface PdfOptions { + format: 'A4' | 'Letter' | 'Legal' + landscape: boolean + printBackground: boolean + margin: { + top: string + right: string + bottom: string + left: string + } +} + +export interface PdfRequestData { + id?: string + url: string + options?: Partial + createdAt?: number +} + +export class PdfRequest { + readonly id: string + readonly url: string + readonly options: PdfOptions + readonly createdAt: number + + constructor(data: PdfRequestData) { + this.id = data.id ?? crypto.randomUUID() + this.url = data.url + this.createdAt = data.createdAt ?? Date.now() + this.options = { + format: data.options?.format ?? 'A4', + landscape: data.options?.landscape ?? false, + printBackground: data.options?.printBackground ?? true, + margin: data.options?.margin ?? { + top: '1cm', + right: '1cm', + bottom: '1cm', + left: '1cm' + } + } + } +} diff --git a/cases/case18/src/lib/models/pdf-result.ts b/cases/case18/src/lib/models/pdf-result.ts new file mode 100644 index 0000000..eb48d3b --- /dev/null +++ b/cases/case18/src/lib/models/pdf-result.ts @@ -0,0 +1,41 @@ +// ============================================================================= +// PdfResult โ€” Transportable class for PDF generation results +// ============================================================================= + +export interface PdfResultData { + requestId: string + success: boolean + pdfBase64?: string + error?: string + generatedAt?: number + durationMs: number +} + +export class PdfResult { + readonly requestId: string + readonly success: boolean + readonly pdfBase64?: string + readonly error?: string + readonly generatedAt: number + readonly durationMs: number + + constructor(data: PdfResultData) { + this.requestId = data.requestId + this.success = data.success + this.pdfBase64 = data.pdfBase64 + this.error = data.error + this.generatedAt = data.generatedAt ?? Date.now() + this.durationMs = data.durationMs + } + + /** Get PDF as Uint8Array */ + get pdfData(): Uint8Array | undefined { + if (!this.pdfBase64) return undefined + return new Uint8Array([...atob(this.pdfBase64)].map((c) => c.charCodeAt(0))) + } + + /** Check if result is an error */ + get isError(): boolean { + return !this.success + } +} diff --git a/cases/case18/src/lib/models/user-presence.ts b/cases/case18/src/lib/models/user-presence.ts new file mode 100644 index 0000000..ec6a5cb --- /dev/null +++ b/cases/case18/src/lib/models/user-presence.ts @@ -0,0 +1,29 @@ +// ============================================================================= +// UserPresence โ€” Transportable class for user presence info +// ============================================================================= + +export interface UserPresenceData { + id: string + username: string + status?: 'online' | 'away' | 'offline' + lastSeen?: number +} + +export class UserPresence { + readonly id: string + readonly username: string + readonly status: 'online' | 'away' | 'offline' + readonly lastSeen: number + + constructor(data: UserPresenceData) { + this.id = data.id + this.username = data.username + this.status = data.status ?? 'online' + this.lastSeen = data.lastSeen ?? Date.now() + } + + /** Check if user is currently online */ + get isOnline(): boolean { + return this.status === 'online' + } +} diff --git a/cases/case18/src/routes/+layout.svelte b/cases/case18/src/routes/+layout.svelte new file mode 100644 index 0000000..f4900af --- /dev/null +++ b/cases/case18/src/routes/+layout.svelte @@ -0,0 +1,136 @@ + + +
+
+

Case 18: SvelteKit Full Demo

+

Cloudflare Workers + All Bindings

+
+ + + +
+ {@render children()} +
+ +
+

Powered by devflare โ€ข SvelteKit + Cloudflare Workers

+
+
+ + diff --git a/cases/case18/src/routes/+page.svelte b/cases/case18/src/routes/+page.svelte new file mode 100644 index 0000000..e4ebb40 --- /dev/null +++ b/cases/case18/src/routes/+page.svelte @@ -0,0 +1,226 @@ + + +
+
+

Welcome to Case 18

+

+ This is a comprehensive SvelteKit application demonstrating ALL major + Cloudflare Worker bindings through devflare. +

+
+ +
+

Available Features

+ +
+ +
+

Technology Stack

+
    +
  • SvelteKit 2 with Svelte 5
  • +
  • @sveltejs/adapter-cloudflare for Workers deployment
  • +
  • devflare for configuration management
  • +
  • Vite with @cloudflare/vite-plugin
  • +
  • TypeScript with strict types
  • +
+
+ +
+

Cloudflare Bindings Used

+
+
+ R2 + Object storage for images +
+
+ KV + Key-value storage +
+
+ D1 + SQLite database +
+
+ DO + Durable Objects (ChatRoom, PdfRenderer) +
+
+ Browser + Browser Rendering for PDF +
+
+
+
+ + diff --git a/cases/case18/src/routes/api/local-bindings/+server.ts b/cases/case18/src/routes/api/local-bindings/+server.ts new file mode 100644 index 0000000..69f7ad9 --- /dev/null +++ b/cases/case18/src/routes/api/local-bindings/+server.ts @@ -0,0 +1,77 @@ +import type { RequestHandler } from './$types' +import { env } from 'devflare' + +const PNG_1X1_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + +function streamFromText(text: string): ReadableStream { + return new Response(text).body! +} + +function streamFromBase64(base64: string): ReadableStream { + const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)) + return new Response(bytes).body! +} + +export const GET: RequestHandler = async () => { + const workflow = await env.ORDER_WORKFLOW.create({ + id: 'case18-order-1', + params: { orderId: 'case18-order-1', total: 42 } + }) + const workflowStatus = await workflow.status() + + const imageInfo = await env.IMAGES_SERVICE.info(streamFromBase64(PNG_1X1_BASE64)) + const imageTransformer = await env.IMAGES_SERVICE.input(streamFromBase64(PNG_1X1_BASE64)) + const transformedImage = await imageTransformer.transform({ width: 1 }) + const imageOutput = await transformedImage.output({ format: 'image/png' }) + const imageResponse = await imageOutput.response() + + const mediaTransformer = await env.MEDIA_SERVICE.input(streamFromText('case18 media payload')) + const mediaOutput = await mediaTransformer.output({ format: 'video/mp4' }) + const mediaResponse = await mediaOutput.response() + + await env.EMAIL.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Case 18 local binding probe', + text: 'Sent through the local Send Email binding' + }) + + const loadedWorker = await env.WORKER_LOADER.load({ + compatibilityDate: '2026-04-27', + mainModule: 'worker.js', + modules: { + 'worker.js': { + js: "export default { async fetch() { return new Response('case18-loader-ok') } }" + } + } + }) + const loadedEntrypoint = loadedWorker.getEntrypoint() + const loadedResponse = await loadedEntrypoint.fetch('https://example.com/') + + return Response.json({ + secret: await env.API_TOKEN.get(), + hyperdrive: { + connectionString: env.POSTGRES.connectionString, + database: env.POSTGRES.database + }, + workflow: { + id: workflow.id, + status: workflowStatus.status + }, + images: { + width: imageInfo.width, + contentType: await imageOutput.contentType(), + status: imageResponse.status + }, + media: { + contentType: await mediaOutput.contentType(), + status: mediaResponse.status + }, + workerLoader: { + status: loadedResponse.status, + text: await loadedResponse.text() + }, + email: 'sent' + }) +} diff --git a/cases/case18/src/routes/api/test-env/+server.ts b/cases/case18/src/routes/api/test-env/+server.ts new file mode 100644 index 0000000..02879d3 --- /dev/null +++ b/cases/case18/src/routes/api/test-env/+server.ts @@ -0,0 +1,24 @@ +// ============================================================================= +// Test Route โ€” Demonstrates unified env from devflare +// ============================================================================= + +import type { RequestHandler } from './$types' +import { env } from 'devflare' + +/** + * GET /api/test-env - Test unified env from devflare + */ +export const GET: RequestHandler = async () => { + try { + // Test: Access KV via unified env (context โ†’ bridge fallback) + await env.CACHE.put('__test', 'hello') + const value = await env.CACHE.get('__test') + + return Response.json({ ok: true, value }) + } catch (e) { + return Response.json({ + ok: false, + error: e instanceof Error ? e.message : String(e) + }, { status: 500 }) + } +} diff --git a/cases/case18/src/routes/chat/+page.svelte b/cases/case18/src/routes/chat/+page.svelte new file mode 100644 index 0000000..30ee2ba --- /dev/null +++ b/cases/case18/src/routes/chat/+page.svelte @@ -0,0 +1,443 @@ + + +
+

๐Ÿ’ฌ Real-time Chat (WebSocket DO)

+

+ WebSocket-based chat using Durable Objects with hibernation. +

+ + {#if !connected} +
+

Join Chat Room

+
+ + +
+
+ + +
+ +
+ {:else} +
+
+
+ ๐Ÿ“ {roomId} + ๐Ÿ‘ฅ {onlineCount} online +
+ +
+ +
+ {#if messages.length === 0} +
+

No messages yet. Say something!

+
+ {:else} + {#each messages as message} +
+ {#if !message.isSystem} +
+ {message.username} + {formatTime(message.timestamp)} +
+ {/if} +
+ {message.content} +
+
+ {/each} + {/if} + + {#if typingUsers.size > 0} +
+ {[...typingUsers].join(', ')} {typingUsers.size === 1 ? 'is' : 'are'} typing... +
+ {/if} +
+ +
+ + +
+
+ {/if} +
+ + diff --git a/cases/case18/src/routes/chat/api/+server.ts b/cases/case18/src/routes/chat/api/+server.ts new file mode 100644 index 0000000..95b4af4 --- /dev/null +++ b/cases/case18/src/routes/chat/api/+server.ts @@ -0,0 +1,70 @@ +import type { RequestHandler } from './$types' + +/** + * GET /chat/api - WebSocket upgrade endpoint for ChatRoom DO + * + * Architecture: + * - SvelteKit calls ChatRoom DO via DurableObjectNamespace binding + * - Works in production directly + * - In dev: SvelteKit + Cloudflare DO dev support is BLOCKED on SvelteKit 3.0 + * See: https://github.com/sveltejs/kit/pull/14008 + * Current workaround: Build first, then run wrangler dev with multi-config + */ +export const GET: RequestHandler = async ({ request, platform, url }) => { + try { + if (!platform?.env?.CHAT_ROOM) { + return new Response(JSON.stringify({ + error: 'CHAT_ROOM binding not available', + hint: 'In dev mode, DOs require SvelteKit 3.0 (PR #14008). Use build-first approach: vite build && wrangler dev -c .devflare/wrangler.jsonc -c .devflare/wrangler.do.jsonc', + hasPlatform: !!platform, + hasEnv: !!platform?.env, + envKeys: platform?.env ? Object.keys(platform.env) : [] + }, null, 2), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }) + } + + const roomId = url.searchParams.get('roomId') || 'default' + const username = url.searchParams.get('username') || 'Anonymous' + const userId = url.searchParams.get('userId') || crypto.randomUUID() + + // Get or create DO instance for this room + const doId = platform.env.CHAT_ROOM.idFromName(roomId) + const stub = platform.env.CHAT_ROOM.get(doId) + + // Construct the request URL for the DO + const doUrl = new URL('/websocket', url.origin) + doUrl.searchParams.set('roomId', roomId) + doUrl.searchParams.set('username', username) + doUrl.searchParams.set('userId', userId) + + // Forward request to DO + const doResponse = await stub.fetch(doUrl.toString(), { + method: request.method, + headers: request.headers + }) + + // For WebSocket upgrade, return the response directly + const upgradeHeader = request.headers.get('Upgrade') + if (upgradeHeader === 'websocket') { + return doResponse as unknown as Response + } + + // For regular requests, reconstruct response for SvelteKit compatibility + const body = await doResponse.text() + return new Response(body, { + status: doResponse.status, + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('[chat/api] Error:', error) + return new Response(JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + hint: 'In dev mode, stub.fetch() to external DO workers does not work with getPlatformProxy. See GitHub issue #5918.' + }, null, 2), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } +} diff --git a/cases/case18/src/routes/db/+page.svelte b/cases/case18/src/routes/db/+page.svelte new file mode 100644 index 0000000..17789fd --- /dev/null +++ b/cases/case18/src/routes/db/+page.svelte @@ -0,0 +1,409 @@ + + +
+

๐Ÿ—ƒ๏ธ Database (D1)

+

Todo list using Cloudflare D1 SQLite database.

+ + {#if error} +
+

โŒ {error}

+ +
+ {/if} + +
+ e.key === 'Enter' && addTodo()} + /> + +
+ +
+ {todos.length} total + {completedCount} completed + {todos.length - completedCount} remaining +
+ + {#if loading} +
Loading todos...
+ {:else if todos.length === 0} +
+

No todos yet. Add one above!

+
+ {:else} +
    + {#each todos as todo} +
  • + + + {#if editingId === todo.id} + e.key === 'Enter' && saveEdit()} + /> + {:else} + startEdit(todo)} + onkeydown={(e) => e.key === 'Enter' && startEdit(todo)} + role="button" + tabindex="0" + aria-label={`Edit todo: ${todo.title}`} + > + {todo.title} + + {/if} + + {formatDate(todo.created_at)} + + +
  • + {/each} +
+ {/if} +
+ + diff --git a/cases/case18/src/routes/db/+server.ts b/cases/case18/src/routes/db/+server.ts new file mode 100644 index 0000000..4e8ea76 --- /dev/null +++ b/cases/case18/src/routes/db/+server.ts @@ -0,0 +1,152 @@ +import type { RequestHandler } from './$types' + +interface Todo { + id: number + title: string + completed: boolean + created_at: string +} + +/** + * GET /db - List todos + * + * Note: Table is created via migration (migrations/0001_create_todos.sql) + * Run: wrangler d1 migrations apply DB --local + */ +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.DB) { + return new Response('D1 binding not available', { status: 503 }) + } + + try { + const result = await platform.env.DB.prepare( + 'SELECT * FROM todos ORDER BY created_at DESC' + ).all() + + return Response.json({ todos: result.results }) + } catch (error) { + console.error('DB error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Database error' }, + { status: 500 } + ) + } +} + +/** + * POST /db - Create a todo + */ +export const POST: RequestHandler = async ({ request, platform }) => { + if (!platform?.env?.DB) { + return new Response('D1 binding not available', { status: 503 }) + } + + try { + const body = await request.json() as { title: string } + + if (!body.title?.trim()) { + return Response.json({ error: 'Title is required' }, { status: 400 }) + } + + const result = await platform.env.DB.prepare( + 'INSERT INTO todos (title) VALUES (?) RETURNING *' + ) + .bind(body.title.trim()) + .first() + + return Response.json({ todo: result, success: true }) + } catch (error) { + console.error('DB insert error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Insert failed' }, + { status: 500 } + ) + } +} + +/** + * PATCH /db - Update a todo + */ +export const PATCH: RequestHandler = async ({ request, platform }) => { + if (!platform?.env?.DB) { + return new Response('D1 binding not available', { status: 503 }) + } + + try { + const body = await request.json() as { + id: number + title?: string + completed?: boolean + } + + if (!body.id) { + return Response.json({ error: 'ID is required' }, { status: 400 }) + } + + const updates: string[] = [] + const values: unknown[] = [] + + if (body.title !== undefined) { + updates.push('title = ?') + values.push(body.title) + } + + if (body.completed !== undefined) { + updates.push('completed = ?') + values.push(body.completed ? 1 : 0) + } + + if (updates.length === 0) { + return Response.json({ error: 'No updates provided' }, { status: 400 }) + } + + values.push(body.id) + + const result = await platform.env.DB.prepare( + `UPDATE todos SET ${updates.join(', ')} WHERE id = ? RETURNING *` + ) + .bind(...values) + .first() + + if (!result) { + return Response.json({ error: 'Todo not found' }, { status: 404 }) + } + + return Response.json({ todo: result, success: true }) + } catch (error) { + console.error('DB update error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Update failed' }, + { status: 500 } + ) + } +} + +/** + * DELETE /db - Delete a todo + */ +export const DELETE: RequestHandler = async ({ url, platform }) => { + if (!platform?.env?.DB) { + return new Response('D1 binding not available', { status: 503 }) + } + + try { + const id = url.searchParams.get('id') + + if (!id) { + return Response.json({ error: 'ID is required' }, { status: 400 }) + } + + await platform.env.DB.prepare('DELETE FROM todos WHERE id = ?') + .bind(parseInt(id)) + .run() + + return Response.json({ success: true, deleted: parseInt(id) }) + } catch (error) { + console.error('DB delete error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Delete failed' }, + { status: 500 } + ) + } +} diff --git a/cases/case18/src/routes/images/+page.svelte b/cases/case18/src/routes/images/+page.svelte new file mode 100644 index 0000000..ef9020a --- /dev/null +++ b/cases/case18/src/routes/images/+page.svelte @@ -0,0 +1,264 @@ + + + + + diff --git a/cases/case18/src/routes/images/+server.ts b/cases/case18/src/routes/images/+server.ts new file mode 100644 index 0000000..ba7c638 --- /dev/null +++ b/cases/case18/src/routes/images/+server.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from './$types' + +/** + * GET /images - List all images + */ +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.IMAGES) { + return new Response('R2 binding not available', { status: 503 }) + } + + try { + const list = await platform.env.IMAGES.list({ limit: 100 }) + + const images = list.objects.map((obj) => ({ + key: obj.key, + size: obj.size, + // Handle both Date objects and ISO strings (from RPC serialization) + uploaded: typeof obj.uploaded === 'string' ? obj.uploaded : obj.uploaded?.toISOString(), + etag: obj.etag + })) + + return Response.json({ images, truncated: list.truncated }) + } catch (error) { + console.error('List error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to list images' }, + { status: 500 } + ) + } +} diff --git a/cases/case18/src/routes/images/[key]/+server.ts b/cases/case18/src/routes/images/[key]/+server.ts new file mode 100644 index 0000000..881e6d1 --- /dev/null +++ b/cases/case18/src/routes/images/[key]/+server.ts @@ -0,0 +1,54 @@ +import type { RequestHandler } from './$types' + +/** + * GET /images/[key] - Get a specific image from R2 + */ +export const GET: RequestHandler = async ({ params, platform }) => { + if (!platform?.env?.IMAGES) { + return new Response('R2 binding not available', { status: 503 }) + } + + try { + const object = await platform.env.IMAGES.get(params.key) + + if (!object) { + return new Response('Image not found', { status: 404 }) + } + + // Build headers manually to avoid miniflare serialization issues + const headers = new Headers() + + // Get content type from http metadata if available + const contentType = object.httpMetadata?.contentType || 'application/octet-stream' + headers.set('Content-Type', contentType) + headers.set('ETag', object.etag) + headers.set('Cache-Control', 'public, max-age=31536000') + + // Convert R2ObjectBody to ArrayBuffer for Response compatibility + const arrayBuffer = await object.arrayBuffer() + return new Response(arrayBuffer, { headers }) + } catch (error) { + console.error('Get image error:', error) + return new Response('Failed to get image', { status: 500 }) + } +} + +/** + * DELETE /images/[key] - Delete an image from R2 + */ +export const DELETE: RequestHandler = async ({ params, platform }) => { + if (!platform?.env?.IMAGES) { + return new Response('R2 binding not available', { status: 503 }) + } + + try { + await platform.env.IMAGES.delete(params.key) + return Response.json({ success: true, deleted: params.key }) + } catch (error) { + console.error('Delete error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to delete' }, + { status: 500 } + ) + } +} diff --git a/cases/case18/src/routes/kv/+page.svelte b/cases/case18/src/routes/kv/+page.svelte new file mode 100644 index 0000000..cac060f --- /dev/null +++ b/cases/case18/src/routes/kv/+page.svelte @@ -0,0 +1,414 @@ + + +
+

๐Ÿ—„๏ธ KV Storage

+

Key-value storage using Cloudflare KV namespace.

+ +
+
+

Add/Update Key

+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+

Keys ({keys.length})

+ +
+ + {#if error} +
{error}
+ {/if} + + {#if loading} +
Loading...
+ {:else if keys.length === 0} +
No keys found
+ {:else} +
    + {#each keys as key} +
  • + + + {formatExpiration(key.expiration)} + + +
  • + {/each} +
+ {/if} +
+ +
+

Value

+ {#if selectedKey} +
+ {selectedKey} +
+ {#if loadingValue} +
Loading...
+ {:else if selectedValue !== null} +
{JSON.stringify(selectedValue, null, 2)}
+ {/if} + {:else} +
Select a key to view its value
+ {/if} +
+
+
+ + diff --git a/cases/case18/src/routes/kv/+server.ts b/cases/case18/src/routes/kv/+server.ts new file mode 100644 index 0000000..1f8c61b --- /dev/null +++ b/cases/case18/src/routes/kv/+server.ts @@ -0,0 +1,99 @@ +import type { RequestHandler } from './$types' + +/** + * GET /kv - List KV entries or get a specific key + */ +export const GET: RequestHandler = async ({ url, platform }) => { + if (!platform?.env?.CACHE) { + return new Response('KV binding not available', { status: 503 }) + } + + const key = url.searchParams.get('key') + + if (key) { + // Get specific key + const value = await platform.env.CACHE.get(key) + if (value === null) { + return Response.json({ error: 'Key not found' }, { status: 404 }) + } + + // Try to parse as JSON + try { + const parsed = JSON.parse(value) + return Response.json({ key, value: parsed, type: 'json' }) + } catch { + return Response.json({ key, value, type: 'string' }) + } + } + + // List all keys + const list = await platform.env.CACHE.list({ limit: 100 }) + + return Response.json({ + keys: list.keys.map((k) => ({ + name: k.name, + expiration: k.expiration, + metadata: k.metadata + })), + truncated: list.list_complete === false + }) +} + +/** + * POST /kv - Set a KV entry + */ +export const POST: RequestHandler = async ({ request, platform }) => { + if (!platform?.env?.CACHE) { + return new Response('KV binding not available', { status: 503 }) + } + + try { + const body = await request.json() as { + key: string + value: unknown + expirationTtl?: number + metadata?: Record + } + + if (!body.key) { + return Response.json({ error: 'Key is required' }, { status: 400 }) + } + + const value = typeof body.value === 'string' + ? body.value + : JSON.stringify(body.value) + + await platform.env.CACHE.put(body.key, value, { + expirationTtl: body.expirationTtl, + metadata: body.metadata + }) + + return Response.json({ + success: true, + key: body.key, + size: value.length + }) + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to set key' }, + { status: 500 } + ) + } +} + +/** + * DELETE /kv - Delete a KV entry + */ +export const DELETE: RequestHandler = async ({ url, platform }) => { + if (!platform?.env?.CACHE) { + return new Response('KV binding not available', { status: 503 }) + } + + const key = url.searchParams.get('key') + if (!key) { + return Response.json({ error: 'Key is required' }, { status: 400 }) + } + + await platform.env.CACHE.delete(key) + return Response.json({ success: true, deleted: key }) +} diff --git a/cases/case18/src/routes/pdf/+page.svelte b/cases/case18/src/routes/pdf/+page.svelte new file mode 100644 index 0000000..b022965 --- /dev/null +++ b/cases/case18/src/routes/pdf/+page.svelte @@ -0,0 +1,436 @@ + + +
+

๐Ÿ“„ PDF Generator

+

+ Generate PDFs from URLs using Browser Rendering in a Durable Object. +

+ + {#if stats} +
+
+ {stats.generated} + Generated +
+
+ {stats.cacheSize} + Cached +
+
+ {stats.successRate.toFixed(0)}% + Success Rate +
+
+ {/if} + +
+
+ + +
+ +
+
+ + +
+ + + + +
+ + +
+ + {#if error} +
+

โŒ {error}

+
+ {/if} + + {#if result?.success} +
+

โœ… PDF Generated!

+
+ {#if result.cached} + Cached + {/if} + {#if result.durationMs} + {result.durationMs}ms + {/if} +
+ +
+ + View PDF + + +
+ + {#if result.pdfUrl} +
+ +
+ {/if} +
+ {/if} + +
+

How it works

+
    +
  1. Your request is sent to a Durable Object (PdfRenderer)
  2. +
  3. The DO uses the Browser Rendering binding to launch Puppeteer
  4. +
  5. Puppeteer navigates to the URL and generates a PDF
  6. +
  7. The PDF is cached in DO storage for 5 minutes
  8. +
  9. The PDF is returned as a downloadable file
  10. +
+
+
+ + diff --git a/cases/case18/src/routes/pdf/+server.ts b/cases/case18/src/routes/pdf/+server.ts new file mode 100644 index 0000000..e4aa239 --- /dev/null +++ b/cases/case18/src/routes/pdf/+server.ts @@ -0,0 +1,115 @@ +import type { RequestHandler } from './$types' +import { PdfRequest, type PdfOptions, type PdfRequestData } from '$lib/models' + +/** + * POST /pdf - Generate a PDF from URL via PDF_RENDERER Durable Object + * + * Architecture: + * - SvelteKit โ†’ PDF_RENDERER (DurableObjectNamespace) โ†’ PdfRenderer DO + * - Works both locally (via multi-worker wrangler dev) and in production + */ +export const POST: RequestHandler = async ({ request, platform }) => { + if (!platform?.env?.PDF_RENDERER) { + return new Response('PDF_RENDERER binding not available', { status: 503 }) + } + + try { + const body = await request.json() as { url: string; options?: Partial } + + if (!body.url) { + return Response.json({ error: 'URL is required' }, { status: 400 }) + } + + // Validate URL + try { + new URL(body.url) + } catch { + return Response.json({ error: 'Invalid URL' }, { status: 400 }) + } + + // Create PDF request + const pdfRequest = new PdfRequest({ + url: body.url, + options: body.options + }) + + // Get DO stub via namespace + const doId = platform.env.PDF_RENDERER.idFromName('renderer') + const stub = platform.env.PDF_RENDERER.get(doId) + + // Serialize for transmission to DO + const encoded: PdfRequestData = { + id: pdfRequest.id, + url: pdfRequest.url, + options: pdfRequest.options, + createdAt: pdfRequest.createdAt + } + + // Call the DO's fetch handler + const doResponse = await stub.fetch('http://do/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(encoded) + }) + + // Reconstruct Response to ensure type compatibility + const arrayBuffer = await doResponse.arrayBuffer() + + const headers = new Headers() + + // Copy relevant headers + const contentType = doResponse.headers.get('Content-Type') + if (contentType) headers.set('Content-Type', contentType) + + const requestId = doResponse.headers.get('X-Request-Id') + if (requestId) headers.set('X-Request-Id', requestId) + + const cached = doResponse.headers.get('X-Cached') + if (cached) headers.set('X-Cached', cached) + + const durationMs = doResponse.headers.get('X-Duration-Ms') + if (durationMs) headers.set('X-Duration-Ms', durationMs) + + return new Response(arrayBuffer, { + status: doResponse.status, + headers + }) + } catch (error) { + console.error('PDF generation error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'PDF generation failed' }, + { status: 500 } + ) + } +} + +/** + * GET /pdf - Get renderer stats via PDF_RENDERER Durable Object + */ +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.PDF_RENDERER) { + return new Response('PDF_RENDERER binding not available', { status: 503 }) + } + + try { + // Get DO stub via namespace + const doId = platform.env.PDF_RENDERER.idFromName('renderer') + const stub = platform.env.PDF_RENDERER.get(doId) + + // Call the DO's fetch handler + const doResponse = await stub.fetch('http://do/stats') + + // Reconstruct Response + const body = await doResponse.text() + return new Response(body, { + status: doResponse.status, + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Stats error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to get stats' }, + { status: 500 } + ) + } +} diff --git a/cases/case18/src/routes/service-action/+page.server.ts b/cases/case18/src/routes/service-action/+page.server.ts new file mode 100644 index 0000000..c1278b8 --- /dev/null +++ b/cases/case18/src/routes/service-action/+page.server.ts @@ -0,0 +1,60 @@ +import type { Actions, PageServerLoad } from './$types' + +interface Case18ApiService { + fetch(request: Request): Promise +} + +function readString(value: unknown): string { + return String(value) +} + +export const load: PageServerLoad = async ({ platform }) => { + return { + configuredVar: readString(platform?.env?.CASE18_STRING_VAR), + missingVar: readString(platform?.env?.CASE18_MISSING_VAR) + } +} + +export const actions: Actions = { + default: async ({ request, platform, cookies }) => { + const formData = await request.formData() + const email = String(formData.get('email') ?? '') + const api = platform?.env?.CASE18_API as Case18ApiService | undefined + + if (!api) { + return { + ok: false, + error: 'missing service binding' + } + } + + const response = await api.fetch(new Request( + 'https://case18-service-api.local/auth/magic-link/request', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }) + } + )) + const payload = await response.json() as { + ok: boolean + email?: string + prefix?: string + } + + cookies.set('case18-service-action', payload.email ?? 'missing', { + path: '/', + sameSite: 'lax' + }) + + return { + ok: true, + status: response.status, + serviceHeader: response.headers.get('x-case18-api'), + email: payload.email, + prefix: payload.prefix, + configuredVar: readString(platform?.env?.CASE18_STRING_VAR), + missingVar: readString(platform?.env?.CASE18_MISSING_VAR) + } + } +} diff --git a/cases/case18/src/routes/service-action/+page.svelte b/cases/case18/src/routes/service-action/+page.svelte new file mode 100644 index 0000000..d636a1c --- /dev/null +++ b/cases/case18/src/routes/service-action/+page.svelte @@ -0,0 +1,19 @@ + + +
+ + +
+ +

{data.configuredVar}

+

{data.missingVar}

+ +{#if form?.ok} +

+ service-action:{form.email}:{form.prefix}:{form.serviceHeader}:{form.configuredVar}:{form.missingVar} +

+{:else if form?.error} +

{form.error}

+{/if} diff --git a/cases/case18/src/routes/upload/+page.svelte b/cases/case18/src/routes/upload/+page.svelte new file mode 100644 index 0000000..dc2571c --- /dev/null +++ b/cases/case18/src/routes/upload/+page.svelte @@ -0,0 +1,285 @@ + + +
+

๐Ÿ“ท Image Upload (R2)

+

Upload images to Cloudflare R2 bucket storage.

+ +
+
+ ๐Ÿ“ +

Drag and drop an image here, or

+ +
+
+ + {#if files && files.length > 0} +
+

Selected File

+
+ {files[0].name} + {formatSize(files[0].size)} + {files[0].type} +
+ +
+ {/if} + + {#if result} +
+ {#if result.success} +

โœ… Upload Successful!

+
+
Filename:
+
{result.filename}
+
Original Name:
+
{result.originalName}
+
Size:
+
{formatSize(result.size || 0)}
+
URL:
+
+ {result.url} +
+
+ {:else} +

โŒ Upload Failed

+

{result.error}

+ {/if} +
+ {/if} +
+ + diff --git a/cases/case18/src/routes/upload/+server.ts b/cases/case18/src/routes/upload/+server.ts new file mode 100644 index 0000000..9f3e105 --- /dev/null +++ b/cases/case18/src/routes/upload/+server.ts @@ -0,0 +1,87 @@ +import type { RequestHandler } from './$types' + +/** + * POST /upload - Upload image to R2 + */ +export const POST: RequestHandler = async ({ request, platform }) => { + if (!platform?.env?.IMAGES) { + return new Response('R2 binding not available', { status: 503 }) + } + + try { + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return Response.json({ error: 'No file provided' }, { status: 400 }) + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + return Response.json( + { error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP' }, + { status: 400 } + ) + } + + // Generate unique filename + const ext = file.name.split('.').pop() || 'bin' + const filename = `${Date.now()}-${crypto.randomUUID()}.${ext}` + + // Upload to R2 + const arrayBuffer = await file.arrayBuffer() + await platform.env.IMAGES.put(filename, arrayBuffer, { + httpMetadata: { + contentType: file.type + }, + customMetadata: { + originalName: file.name, + uploadedAt: new Date().toISOString(), + size: String(file.size) + } + }) + + return Response.json({ + success: true, + filename, + originalName: file.name, + size: file.size, + type: file.type, + url: `/images/${filename}` + }) + } catch (error) { + console.error('Upload error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Upload failed' }, + { status: 500 } + ) + } +} + +/** + * GET /upload - Get upload stats + */ +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.IMAGES) { + return new Response('R2 binding not available', { status: 503 }) + } + + try { + const list = await platform.env.IMAGES.list({ limit: 1000 }) + + const stats = { + totalFiles: list.objects.length, + totalSize: list.objects.reduce((sum, obj) => sum + obj.size, 0), + truncated: list.truncated + } + + return Response.json(stats) + } catch (error) { + console.error('Stats error:', error) + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to get stats' }, + { status: 500 } + ) + } +} diff --git a/cases/case18/src/transport.ts b/cases/case18/src/transport.ts new file mode 100644 index 0000000..01d42ef --- /dev/null +++ b/cases/case18/src/transport.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// Transport โ€” SvelteKit signature +// ============================================================================= +// This file ONLY contains the transport object with encode/decode definitions. +// Classes are imported from ./models/ and this works "under the hood" via +// the `transport` config option in devflare.config.ts. +// ============================================================================= + +import { + ChatMessage, + UserPresence, + PdfRequest, + PdfResult, + type ChatMessageData, + type UserPresenceData, + type PdfRequestData, + type PdfResultData +} from './lib/models' + +export const transport = { + ChatMessage: { + encode: (v: unknown): ChatMessageData | false => + v instanceof ChatMessage && { + id: v.id, + userId: v.userId, + username: v.username, + content: v.content, + timestamp: v.timestamp, + roomId: v.roomId + }, + decode: (v: ChatMessageData) => new ChatMessage(v) + }, + UserPresence: { + encode: (v: unknown): UserPresenceData | false => + v instanceof UserPresence && { + id: v.id, + username: v.username, + status: v.status, + lastSeen: v.lastSeen + }, + decode: (v: UserPresenceData) => new UserPresence(v) + }, + PdfRequest: { + encode: (v: unknown): PdfRequestData | false => + v instanceof PdfRequest && { + id: v.id, + url: v.url, + options: v.options, + createdAt: v.createdAt + }, + decode: (v: PdfRequestData) => new PdfRequest(v) + }, + PdfResult: { + encode: (v: unknown): PdfResultData | false => + v instanceof PdfResult && { + requestId: v.requestId, + success: v.success, + pdfBase64: v.pdfBase64, + error: v.error, + generatedAt: v.generatedAt, + durationMs: v.durationMs + }, + decode: (v: PdfResultData) => new PdfResult(v) + } +} diff --git a/cases/case18/src/wf.order.ts b/cases/case18/src/wf.order.ts new file mode 100644 index 0000000..d1dd08e --- /dev/null +++ b/cases/case18/src/wf.order.ts @@ -0,0 +1,15 @@ +import { WorkflowEntrypoint } from 'cloudflare:workers' + +interface OrderWorkflowPayload { + orderId: string + total: number +} + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event, step) { + await step.do('record order', async () => ({ + orderId: event.payload.orderId, + total: event.payload.total + })) + } +} diff --git a/cases/case18/static/favicon.png b/cases/case18/static/favicon.png new file mode 100644 index 0000000..f606d9e --- /dev/null +++ b/cases/case18/static/favicon.png @@ -0,0 +1,4 @@ + + + 18 + diff --git a/cases/case18/svelte.config.js b/cases/case18/svelte.config.js new file mode 100644 index 0000000..34ba66c --- /dev/null +++ b/cases/case18/svelte.config.js @@ -0,0 +1,23 @@ +import adapter from '@sveltejs/adapter-cloudflare' +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + // Use config from .devflare/ (generated by devflare plugin) + config: '.devflare/wrangler.jsonc', + platformProxy: { + // Use the generated config for getPlatformProxy + configPath: '.devflare/wrangler.jsonc', + persist: true + } + }), + alias: { + $lib: './src/lib' + } + } +} + +export default config diff --git a/cases/case18/test-image.png b/cases/case18/test-image.png new file mode 100644 index 0000000..31c3e60 Binary files /dev/null and b/cases/case18/test-image.png differ diff --git a/cases/case18/test-miniflare.ts b/cases/case18/test-miniflare.ts new file mode 100644 index 0000000..4eae88e --- /dev/null +++ b/cases/case18/test-miniflare.ts @@ -0,0 +1,39 @@ +// Test Miniflare with ChatRoom bundle +import { Miniflare, Log, LogLevel } from '../../packages/devflare/node_modules/miniflare' + +async function test() { + const mf = new Miniflare({ + modules: true, + script: `export default { async fetch() { return new Response('ok') } }`, + workers: [{ + name: 'do-chatroom', + modules: true, + scriptPath: '.devflare/do-bundles/ChatRoom/index.js', + compatibilityDate: '2025-01-07', + compatibilityFlags: ['nodejs_compat'], + durableObjects: { CHAT_ROOM: 'ChatRoom' } + }], + durableObjects: { CHAT_ROOM: { className: 'ChatRoom', scriptName: 'do-chatroom' } }, + log: new Log(LogLevel.DEBUG) + }) + + await mf.ready + console.log('โœ… Miniflare started with ChatRoom DO!') + + // Get bindings from the main worker + const bindings = await mf.getBindings() + console.log('โœ… Available bindings:', Object.keys(bindings)) + + // Try to get a DO instance via bindings + const CHAT_ROOM = bindings.CHAT_ROOM as DurableObjectNamespace + const id = CHAT_ROOM.idFromName('test-room') + console.log('โœ… Got DO id:', id.toString()) + + await mf.dispose() + console.log('โœ… Miniflare disposed - ChatRoom DO works!') +} + +test().catch(e => { + console.error('โŒ Test error:', e) + process.exit(1) +}) diff --git a/cases/case18/test-upload.png b/cases/case18/test-upload.png new file mode 100644 index 0000000..319a17c Binary files /dev/null and b/cases/case18/test-upload.png differ diff --git a/cases/case18/tsconfig.json b/cases/case18/tsconfig.json new file mode 100644 index 0000000..616c43b --- /dev/null +++ b/cases/case18/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rootDir": "./", + "types": ["@cloudflare/workers-types", "@sveltejs/kit"], + "skipLibCheck": true + }, + "include": [ + "env.d.ts", + "src/**/*.ts", + "src/**/*.svelte", + "devflare.config.ts", + "vite.config.ts", + ".svelte-kit/types/**/*.d.ts" + ] +} diff --git a/cases/case18/vite.config.ts b/cases/case18/vite.config.ts new file mode 100644 index 0000000..fc03a35 --- /dev/null +++ b/cases/case18/vite.config.ts @@ -0,0 +1,36 @@ +// ============================================================================= +// Case 18: Vite Configuration with Durable Object Support +// ============================================================================= +// This configuration sets up SvelteKit with Cloudflare adapter. +// +// For Durable Objects to work locally: +// - Run `devflare dev` which starts wrangler with multi-config support +// - Main worker: wrangler.jsonc (SvelteKit app with service binding to DO worker) +// - DO worker: wrangler.do.jsonc (exports ChatRoom, PdfRenderer classes) +// - SvelteKit calls DOs via DO_SERVICE service binding +// +// The wrangler.jsonc files are generated by devflare dev. +// ============================================================================= + +import { sveltekit } from '@sveltejs/kit/vite' +import { devflarePlugin } from 'devflare/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + // Devflare plugin (loads config, generates wrangler.jsonc for platformProxy) + // WebSocket routes are configured in devflare.config.ts wsRoutes + devflarePlugin(), + + // SvelteKit (handles the main app with adapter-cloudflare) + sveltekit() + ], + + // Resolve aliases for imports + resolve: { + alias: { + $lib: './src/lib' + } + } +}) + diff --git a/cases/case19/devflare.config.ts b/cases/case19/devflare.config.ts new file mode 100644 index 0000000..c5c0dfb --- /dev/null +++ b/cases/case19/devflare.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case19-minimal-test', + // compatibilityDate is optional - defaults to current date + // compatibilityFlags is optional - nodejs_compat and nodejs_als are always forced + + bindings: { + durableObjects: { + COUNTER: { className: 'Counter', scriptName: 'do.counter.ts' } + } + } +}) diff --git a/cases/case19/env.d.ts b/cases/case19/env.d.ts new file mode 100644 index 0000000..7d1b1a8 --- /dev/null +++ b/cases/case19/env.d.ts @@ -0,0 +1,16 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + COUNTER: DurableObjectNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case19/package.json b/cases/case19/package.json new file mode 100644 index 0000000..e760442 --- /dev/null +++ b/cases/case19/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case19-transport-do-rpc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case19/src/DoubleableNumber.ts b/cases/case19/src/DoubleableNumber.ts new file mode 100644 index 0000000..ee4542e --- /dev/null +++ b/cases/case19/src/DoubleableNumber.ts @@ -0,0 +1,11 @@ +export class DoubleableNumber { + value: number + + constructor(n: number) { + this.value = n + } + + get double() { + return this.value * 2 + } +} \ No newline at end of file diff --git a/cases/case19/src/do.counter.ts b/cases/case19/src/do.counter.ts new file mode 100644 index 0000000..fcca740 --- /dev/null +++ b/cases/case19/src/do.counter.ts @@ -0,0 +1,14 @@ +import { DoubleableNumber } from './DoubleableNumber' + +export class Counter { + private count = 0 + + getValue(): DoubleableNumber { + return new DoubleableNumber(this.count) + } + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +} diff --git a/cases/case19/src/transport.ts b/cases/case19/src/transport.ts new file mode 100644 index 0000000..2060eaa --- /dev/null +++ b/cases/case19/src/transport.ts @@ -0,0 +1,9 @@ +import { DoubleableNumber } from "./DoubleableNumber" + +// SvelteKit transport signature +export const transport = { + DoubleableNumber: { + encode: (v: unknown) => v instanceof DoubleableNumber && v.value, + decode: (v: number) => new DoubleableNumber(v) + } +} diff --git a/cases/case19/tests/counter.test.ts b/cases/case19/tests/counter.test.ts new file mode 100644 index 0000000..6908c0a --- /dev/null +++ b/cases/case19/tests/counter.test.ts @@ -0,0 +1,34 @@ +import { test, expect, beforeAll, afterAll, describe } from 'bun:test' +import { createTestContext } from 'devflare/test' +import { env } from 'devflare' +import { DoubleableNumber } from '../src/DoubleableNumber' + +// ============================================================================= +// Durable Object Test with Transport Types +// ============================================================================= +// NOTE: This test times out when running in parallel with other DO tests +// due to Miniflare port conflicts (all instances try to use port 9799). +// +// This test is skipped in the full test suite to avoid hangs. +// Run standalone with: DEVFLARE_RUN_DO_TESTS=1 bun test cases/case19 +// +// TODO: Fix simple-context.ts to use a random port for each Miniflare instance +// ============================================================================= + +// Skip unless explicitly enabled via DEVFLARE_RUN_DO_TESTS=1 +const runDOTests = process.env.DEVFLARE_RUN_DO_TESTS === '1' + +describe.skipIf(!runDOTests)('Counter DO', () => { + beforeAll(async () => { + await createTestContext() + }) + + afterAll(() => env.dispose()) + + test('getValue returns DoubleableNumber', async () => { + const counter = env.COUNTER.getByName('main') + const result = await counter.getValue() + expect(result.double).toBe(0) + expect(result).toBeInstanceOf(DoubleableNumber) + }, { timeout: 30000 }) +}) diff --git a/cases/case19/tsconfig.json b/cases/case19/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case19/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case3/devflare.config.ts b/cases/case3/devflare.config.ts new file mode 100644 index 0000000..b9c7c8b --- /dev/null +++ b/cases/case3/devflare.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, ref } from 'devflare/config' + +// ============================================================================= +// Case 3: Cross-Worker Durable Objects +// ============================================================================= +// This case demonstrates: +// 1. Local DOs: SESSION and TRACKER (unique to this worker) +// 2. Cross-worker DOs: COUNTER and RATE_LIMITER (hosted by do-service) +// +// Cross-Worker Pattern: +// 1. Create a ref() to the worker that hosts the DOs +// 2. Use ref.BINDING_NAME to get a cross-worker DO binding +// 3. Access the DO like any other: env.COUNTER.idFromName('x').get() +// +// The test context automatically sets up multi-worker Miniflare when it +// detects cross-worker DO bindings (those with __ref). +// ============================================================================= + +const doService = ref(() => import('./do-service/devflare.config')) + +export default defineConfig({ + name: 'case3-durable-objects', + compatibilityDate: '2026-04-26', + + bindings: { + durableObjects: { + // Local DOs โ€” unique to case3, hosted by this worker + // Uses simplified string syntax: BINDING: 'ClassName' + SESSION: 'SessionStore', + TRACKER: 'RequestTracker', + + // Cross-worker DOs โ€” hosted by do-service + // doService.COUNTER returns { className: 'Counter', scriptName: 'do-service', __ref } + COUNTER: doService.COUNTER, + RATE_LIMITER: doService.RATE_LIMITER + } + } +}) diff --git a/cases/case3/do-service/devflare.config.ts b/cases/case3/do-service/devflare.config.ts new file mode 100644 index 0000000..d653766 --- /dev/null +++ b/cases/case3/do-service/devflare.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'do-service', + compatibilityDate: '2026-04-26', + + files: { + // fetch.ts is at root (not in src/), so we must specify it + fetch: 'fetch.ts', + // Non-recursive pattern (intentionally more restrictive than default **/do.*.{ts,js}) + durableObjects: 'do.*.ts' + }, + + bindings: { + // Local bindings for the DOs hosted in this worker + durableObjects: { + COUNTER: { + className: 'Counter', + scriptName: 'do.counter.ts' + }, + RATE_LIMITER: { + className: 'RateLimiter', + scriptName: 'do.rate-limiter.ts' + } + } + } +}) diff --git a/cases/case3/do-service/do.counter.ts b/cases/case3/do-service/do.counter.ts new file mode 100644 index 0000000..d705e1c --- /dev/null +++ b/cases/case3/do-service/do.counter.ts @@ -0,0 +1,51 @@ +// ============================================================================= +// Case 3: DO Service - Counter DO +// ============================================================================= +// Counter Durable Object hosted in a separate worker. +// This demonstrates cross-worker DO access via service bindings. +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' + +/** + * Counter Durable Object + * Provides increment, decrement, and getValue RPC methods. + * + * Extends DurableObject from cloudflare:workers for Miniflare RPC compatibility. + */ +export class Counter extends DurableObject { + private value = 0 + + /** + * RPC method: Get current value + */ + getValue(): number { + return this.value + } + + /** + * RPC method: Increment and return new value + */ + async increment(amount = 1): Promise { + this.value += amount + await this.ctx.storage.put('value', this.value) + return this.value + } + + /** + * RPC method: Decrement and return new value + */ + async decrement(amount = 1): Promise { + this.value -= amount + await this.ctx.storage.put('value', this.value) + return this.value + } + + /** + * RPC method: Reset counter to zero + */ + async reset(): Promise { + this.value = 0 + await this.ctx.storage.delete('value') + } +} diff --git a/cases/case3/do-service/do.rate-limiter.ts b/cases/case3/do-service/do.rate-limiter.ts new file mode 100644 index 0000000..27f0a75 --- /dev/null +++ b/cases/case3/do-service/do.rate-limiter.ts @@ -0,0 +1,64 @@ +// ============================================================================= +// Case 3: DO Service - Rate Limiter DO +// ============================================================================= +// Rate Limiter Durable Object hosted in a separate worker. +// This demonstrates cross-worker DO access via service bindings. +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' + +/** + * Rate Limiter Durable Object + * Implements a sliding window rate limiter. + * + * Extends DurableObject from cloudflare:workers for Miniflare RPC compatibility. + */ +export class RateLimiter extends DurableObject { + private requests: number[] = [] + + /** + * RPC method: Check if rate limit is exceeded + * @param maxRequests - Maximum requests allowed in window + * @param windowMs - Window size in milliseconds + * @returns true if rate limited, false if allowed + */ + async checkLimit(maxRequests = 100, windowMs = 60000): Promise { + const now = Date.now() + const windowStart = now - windowMs + + // Clean old requests outside the window + this.requests = this.requests.filter((t) => t > windowStart) + + if (this.requests.length >= maxRequests) { + return true // Rate limited + } + + // Add current request + this.requests.push(now) + await this.ctx.storage.put('requests', this.requests) + + return false // Allowed + } + + /** + * RPC method: Get remaining requests allowed + */ + getRemaining(maxRequests = 100): number { + return Math.max(0, maxRequests - this.requests.length) + } + + /** + * RPC method: Reset rate limit + */ + async reset(): Promise { + this.requests = [] + await this.ctx.storage.delete('requests') + } + + /** + * RPC method: Get current request count + */ + getCount(): number { + return this.requests.length + } +} diff --git a/cases/case3/do-service/env.d.ts b/cases/case3/do-service/env.d.ts new file mode 100644 index 0000000..587354f --- /dev/null +++ b/cases/case3/do-service/env.d.ts @@ -0,0 +1,20 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + COUNTER: DurableObjectNamespace + RATE_LIMITER: DurableObjectNamespace + } +} + +declare module 'devflare/test' { + interface DevflareEnv { + COUNTER: DurableObjectNamespace + RATE_LIMITER: DurableObjectNamespace + } +} + +export { } diff --git a/cases/case3/do-service/fetch.ts b/cases/case3/do-service/fetch.ts new file mode 100644 index 0000000..d7de019 --- /dev/null +++ b/cases/case3/do-service/fetch.ts @@ -0,0 +1,27 @@ +// ============================================================================= +// Case 3: DO Service - Worker Entrypoint +// ============================================================================= +// This worker hosts the Durable Objects and exposes them via bindings. +// Another worker can access these DOs through service bindings. +// +// The DOs are discovered automatically via files.durableObjects: 'do.*.ts' +// in devflare.config.ts - no need to export them here. +// ============================================================================= + +/** + * Default fetch handler for the DO service worker. + * This worker primarily hosts DOs - the fetch handler is minimal. + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/') { + return Response.json({ + name: 'DO Service Worker', + description: 'Hosts Durable Objects for cross-worker access', + durableObjects: ['Counter', 'RateLimiter'] + }) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case3/do-service/package.json b/cases/case3/do-service/package.json new file mode 100644 index 0000000..4212570 --- /dev/null +++ b/cases/case3/do-service/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devflare/case3-do-service", + "private": true, + "scripts": { + "test": "bun test", + "dev": "devflare dev", + "build": "devflare build", + "deploy": "devflare deploy", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/cases/case3/do-service/tsconfig.json b/cases/case3/do-service/tsconfig.json new file mode 100644 index 0000000..fcb2ae9 --- /dev/null +++ b/cases/case3/do-service/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["env.d.ts", "*.ts"] +} diff --git a/cases/case3/env.d.ts b/cases/case3/env.d.ts new file mode 100644 index 0000000..5952e6e --- /dev/null +++ b/cases/case3/env.d.ts @@ -0,0 +1,19 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + SESSION: DurableObjectNamespace + TRACKER: DurableObjectNamespace + COUNTER: DurableObjectNamespace + RATE_LIMITER: DurableObjectNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case3/package.json b/cases/case3/package.json new file mode 100644 index 0000000..f940fa0 --- /dev/null +++ b/cases/case3/package.json @@ -0,0 +1,20 @@ +{ + "name": "@devflare/case3-durable-objects", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types", + "dev": "devflare dev", + "build": "devflare build", + "deploy": "devflare deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case3/src/do.session.ts b/cases/case3/src/do.session.ts new file mode 100644 index 0000000..3022f2b --- /dev/null +++ b/cases/case3/src/do.session.ts @@ -0,0 +1,72 @@ +// ============================================================================= +// Case 3: Session Store Durable Object (unique to case3) +// ============================================================================= +// Stores user session data in a Durable Object. +// Different from do-service's Counter/RateLimiter โ€” this is for session management. +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' + +/** + * Session value type โ€” serializable primitives. + * RPC requires Rpc.Serializable types (primitives, arrays, plain objects). + */ +export type SessionValue = string | number | boolean | null + +/** + * Session Store Durable Object + * Provides session storage with get, set, and clear methods. + * Data is stored in-memory (for testing) or persisted (in production). + * + * Note: Extends DurableObject from cloudflare:workers for Miniflare RPC compatibility. + * Method names are prefixed to avoid conflicts with base class methods. + */ +export class SessionStore extends DurableObject { + private data: Map = new Map() + private createdAt: number = Date.now() + + /** + * RPC method: Get a value from the session + */ + getValue(key: string): SessionValue { + return this.data.get(key) ?? null + } + + /** + * RPC method: Set a value in the session + */ + setValue(key: string, value: SessionValue): void { + this.data.set(key, value) + } + + /** + * RPC method: Delete a value from the session + */ + deleteValue(key: string): boolean { + return this.data.delete(key) + } + + /** + * RPC method: Clear all session data + */ + clearAll(): void { + this.data.clear() + } + + /** + * RPC method: Get all keys in the session + */ + getAllKeys(): string[] { + return [...this.data.keys()] + } + + /** + * RPC method: Get session metadata + */ + getMetadata(): { itemCount: number; createdAt: number } { + return { + itemCount: this.data.size, + createdAt: this.createdAt + } + } +} diff --git a/cases/case3/src/do.tracker.ts b/cases/case3/src/do.tracker.ts new file mode 100644 index 0000000..844fa48 --- /dev/null +++ b/cases/case3/src/do.tracker.ts @@ -0,0 +1,73 @@ +// ============================================================================= +// Case 3: Request Tracker Durable Object (unique to case3) +// ============================================================================= +// Tracks request statistics in a Durable Object. +// Different from do-service's Counter/RateLimiter โ€” this is for analytics. +// ============================================================================= + +import { DurableObject } from 'cloudflare:workers' + +/** + * Request Tracker Durable Object + * Tracks request counts and timing statistics. + * + * Note: Extends DurableObject from cloudflare:workers for Miniflare RPC compatibility. + */ +export class RequestTracker extends DurableObject { + private totalRequests = 0 + private requestsByPath: Map = new Map() + private lastRequestAt: number | null = null + + /** + * RPC method: Track a request + */ + trackRequest(path: string): { total: number; pathCount: number } { + this.totalRequests++ + const pathCount = (this.requestsByPath.get(path) ?? 0) + 1 + this.requestsByPath.set(path, pathCount) + this.lastRequestAt = Date.now() + + return { total: this.totalRequests, pathCount } + } + + /** + * RPC method: Get total request count + */ + getTotalRequests(): number { + return this.totalRequests + } + + /** + * RPC method: Get request count for a specific path + */ + getPathRequests(path: string): number { + return this.requestsByPath.get(path) ?? 0 + } + + /** + * RPC method: Get all path statistics + */ + getAllPathStats(): Record { + const result: Record = {} + for (const [path, count] of this.requestsByPath) { + result[path] = count + } + return result + } + + /** + * RPC method: Get last request timestamp + */ + getLastRequestAt(): number | null { + return this.lastRequestAt + } + + /** + * RPC method: Reset all tracking data + */ + resetAll(): void { + this.totalRequests = 0 + this.requestsByPath.clear() + this.lastRequestAt = null + } +} diff --git a/cases/case3/src/fetch.ts b/cases/case3/src/fetch.ts new file mode 100644 index 0000000..61b2644 --- /dev/null +++ b/cases/case3/src/fetch.ts @@ -0,0 +1,166 @@ +// ============================================================================= +// Case 3: Durable Objects - Mixed Local and Cross-Worker Pattern +// ============================================================================= +// Demonstrates devflare's patterns for Durable Objects: +// +// LOCAL DOs (this worker): +// - SESSION: SessionStore for user session data +// - TRACKER: RequestTracker for analytics +// +// CROSS-WORKER DOs (hosted by do-service): +// - COUNTER: Counter for counting +// - RATE_LIMITER: RateLimiter for rate limiting +// +// Pattern: +// const doService = ref(() => import('./do-service/devflare.config')) +// bindings: { +// durableObjects: { +// SESSION: 'SessionStore', // Local DO +// TRACKER: 'RequestTracker', // Local DO +// COUNTER: doService.COUNTER, // Cross-worker DO +// RATE_LIMITER: doService.RATE_LIMITER // Cross-worker DO +// } +// } +// ============================================================================= + +import { env } from 'devflare' + +/** + * Main fetch handler + */ +export default async function fetch(request: Request): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return new Response('Case 3: Cross-Worker Durable Objects Demo', { + headers: { 'Content-Type': 'text/plain' } + }) + } + + // ------------------------------------------------------------------------- + // LOCAL DO ROUTES: Session management + // ------------------------------------------------------------------------- + if (url.pathname.startsWith('/session/')) { + const parts = url.pathname.split('/') + const sessionId = parts[2] + const action = parts[3] || 'info' + + const id = env.SESSION.idFromName(sessionId) + const session = env.SESSION.get(id) + + if (action === 'get') { + const key = parts[4] + const value = await session.getValue(key) + return Response.json({ value }) + } + + if (action === 'set') { + const key = parts[4] + const value = url.searchParams.get('value') + await session.setValue(key, value) + return Response.json({ ok: true }) + } + + if (action === 'clear') { + await session.clearAll() + return Response.json({ ok: true }) + } + + if (action === 'info') { + const metadata = await session.getMetadata() + return Response.json(metadata) + } + + return new Response('Unknown action', { status: 400 }) + } + + // ------------------------------------------------------------------------- + // LOCAL DO ROUTES: Request tracking + // ------------------------------------------------------------------------- + if (url.pathname.startsWith('/track/')) { + const trackerId = url.pathname.slice(7) + + const id = env.TRACKER.idFromName('global') + const tracker = env.TRACKER.get(id) + + const result = await tracker.trackRequest(trackerId) + return Response.json(result) + } + + if (url.pathname === '/stats') { + const id = env.TRACKER.idFromName('global') + const tracker = env.TRACKER.get(id) + + const stats = await tracker.getAllPathStats() + return Response.json(stats) + } + + // ------------------------------------------------------------------------- + // CROSS-WORKER DO ROUTES: Counter (hosted by do-service) + // ------------------------------------------------------------------------- + if (url.pathname.startsWith('/counter/')) { + const parts = url.pathname.split('/') + const name = parts[2] + const action = parts[3] || 'value' + + const id = env.COUNTER.idFromName(name) + const counter = env.COUNTER.get(id) + + if (action === 'value') { + const value = await counter.getValue() + return Response.json({ value }) + } + + if (action === 'increment') { + const value = await counter.increment() + return Response.json({ value }) + } + + if (action === 'decrement') { + const value = await counter.decrement() + return Response.json({ value }) + } + + if (action === 'reset') { + await counter.reset() + return Response.json({ value: 0 }) + } + + return new Response('Unknown action', { status: 400 }) + } + + // ------------------------------------------------------------------------- + // CROSS-WORKER DO ROUTES: Rate Limiter (hosted by do-service) + // ------------------------------------------------------------------------- + if (url.pathname.startsWith('/ratelimit/')) { + const key = url.pathname.slice(11) + + const id = env.RATE_LIMITER.idFromName(key) + const limiter = env.RATE_LIMITER.get(id) + + const limited = await limiter.checkLimit(10, 60000) + + if (limited) { + return new Response('Rate limited', { + status: 429, + headers: { + 'X-RateLimit-Remaining': '0', + 'Retry-After': '60' + } + }) + } + + const remaining = await limiter.getRemaining(10) + return Response.json( + { ok: true, remaining }, + { + headers: { + 'X-RateLimit-Remaining': remaining.toString() + } + } + ) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case3/test-types.ts b/cases/case3/test-types.ts new file mode 100644 index 0000000..3e60497 --- /dev/null +++ b/cases/case3/test-types.ts @@ -0,0 +1,16 @@ +import { defineConfig, ref } from 'devflare/config' + +const doService = ref(() => import('./do-service/devflare.config')) + +// Test: With ref() - does this cause tsc error? +const config = defineConfig({ + name: 'test', + bindings: { + durableObjects: { + SESSION: 'SessionStore', + COUNTER: doService.COUNTER + } + } +}) + +export default config diff --git a/cases/case3/tests/durable-objects.test.ts b/cases/case3/tests/durable-objects.test.ts new file mode 100644 index 0000000..f4e9718 --- /dev/null +++ b/cases/case3/tests/durable-objects.test.ts @@ -0,0 +1,419 @@ +// ============================================================================= +// Case 3: Durable Objects - Mixed Local and Cross-Worker Pattern Tests +// ============================================================================= +// Tests for both local DOs (SESSION, TRACKER) and cross-worker DOs (COUNTER, RATE_LIMITER). +// +// LOCAL DOs (this worker): +// - SESSION: SessionStore for user session data +// - TRACKER: RequestTracker for analytics +// +// CROSS-WORKER DOs (hosted by do-service): +// - COUNTER: Counter for counting +// - RATE_LIMITER: RateLimiter for rate limiting +// +// Pattern: +// - Local DOs are plain classes in src/do.*.ts (SessionStore, RequestTracker) +// - Cross-worker DOs are in do-service/src/do.*.ts (Counter, RateLimiter) +// - The test context sets up multi-worker Miniflare automatically +// - Tests call RPC methods via env.BINDING.get(id).method() +// ============================================================================= + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { env } from 'devflare' +import { createTestContext } from 'devflare/test' + +// Import fetch handler for route testing +import fetch from '../src/fetch' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +const TEST_CONTEXT_TIMEOUT_MS = 15_000 + +beforeAll(async () => { + await createTestContext() +}, TEST_CONTEXT_TIMEOUT_MS) + +afterAll(async () => { + await env.dispose() +}, TEST_CONTEXT_TIMEOUT_MS) + +// ----------------------------------------------------------------------------- +// Local DO Tests: SessionStore +// ----------------------------------------------------------------------------- + +describe('Case 3: Local DOs', () => { + describe('SessionStore DO (local)', () => { + test('can get DO stub via SESSION binding', async () => { + const id = env.SESSION.idFromName('test-session') + const stub = env.SESSION.get(id) + expect(stub).toBeDefined() + }) + + test('can set and get session values', async () => { + const id = env.SESSION.idFromName('session-crud') + const session = env.SESSION.get(id) + + // Set a value + await session.setValue('username', 'alice') + await session.setValue('role', 'admin') + + // Get values + const username = await session.getValue('username') + const role = await session.getValue('role') + + expect(username).toBe('alice') + expect(role).toBe('admin') + }) + + test('can delete session values', async () => { + const id = env.SESSION.idFromName('session-delete') + const session = env.SESSION.get(id) + + // Set and delete + await session.setValue('temp', 'value') + const deleted = await session.deleteValue('temp') + expect(deleted).toBe(true) + + // Verify deleted + const value = await session.getValue('temp') + expect(value).toBe(null) + }) + + test('can clear all session data', async () => { + const id = env.SESSION.idFromName('session-clear') + const session = env.SESSION.get(id) + + // Set multiple values + await session.setValue('a', 1) + await session.setValue('b', 2) + await session.setValue('c', 3) + + // Clear + await session.clearAll() + + // Verify cleared + const keys = await session.getAllKeys() + expect(keys).toEqual([]) + }) + + test('can get session metadata', async () => { + const id = env.SESSION.idFromName('session-meta') + const session = env.SESSION.get(id) + + await session.clearAll() + await session.setValue('x', 1) + await session.setValue('y', 2) + + const meta = await session.getMetadata() + expect(meta.itemCount).toBe(2) + expect(typeof meta.createdAt).toBe('number') + }) + }) + + describe('RequestTracker DO (local)', () => { + test('can get DO stub via TRACKER binding', async () => { + const id = env.TRACKER.idFromName('test-tracker') + const stub = env.TRACKER.get(id) + expect(stub).toBeDefined() + }) + + test('tracks request counts', async () => { + const id = env.TRACKER.idFromName('tracker-counts') + const tracker = env.TRACKER.get(id) + + await tracker.resetAll() + + // Track requests + const r1 = await tracker.trackRequest('/api/users') + expect(r1.total).toBe(1) + expect(r1.pathCount).toBe(1) + + const r2 = await tracker.trackRequest('/api/users') + expect(r2.total).toBe(2) + expect(r2.pathCount).toBe(2) + + const r3 = await tracker.trackRequest('/api/posts') + expect(r3.total).toBe(3) + expect(r3.pathCount).toBe(1) + }) + + test('can get path statistics', async () => { + const id = env.TRACKER.idFromName('tracker-stats') + const tracker = env.TRACKER.get(id) + + await tracker.resetAll() + + await tracker.trackRequest('/a') + await tracker.trackRequest('/a') + await tracker.trackRequest('/b') + + const stats = await tracker.getAllPathStats() + expect(stats['/a']).toBe(2) + expect(stats['/b']).toBe(1) + }) + + test('tracks last request timestamp', async () => { + const id = env.TRACKER.idFromName('tracker-timestamp') + const tracker = env.TRACKER.get(id) + + await tracker.resetAll() + expect(await tracker.getLastRequestAt()).toBeNull() + + await tracker.trackRequest('/test') + const firstAt = await tracker.getLastRequestAt() + + expect(typeof firstAt).toBe('number') + + await tracker.trackRequest('/test') + const secondAt = await tracker.getLastRequestAt() + expect(secondAt).toBeGreaterThanOrEqual(firstAt as number) + }) + }) +}) + +// ----------------------------------------------------------------------------- +// Cross-Worker DO Tests: Counter, RateLimiter +// ----------------------------------------------------------------------------- + +describe('Case 3: Cross-Worker DOs', () => { + describe('Counter DO (cross-worker)', () => { + test('can get DO stub via COUNTER binding', async () => { + const id = env.COUNTER.idFromName('test-counter') + const stub = env.COUNTER.get(id) + expect(stub).toBeDefined() + }) + + test('increment increases value via RPC', async () => { + const id = env.COUNTER.idFromName('rpc-counter-1') + const counter = env.COUNTER.get(id) + + // Reset first + await counter.reset() + + // Increment + const result = await counter.increment(5) + expect(result).toBe(5) + + // Check value + const value = await counter.getValue() + expect(value).toBe(5) + + // Increment again + const result2 = await counter.increment(3) + expect(result2).toBe(8) + }) + + test('decrement decreases value via RPC', async () => { + const id = env.COUNTER.idFromName('rpc-counter-2') + const counter = env.COUNTER.get(id) + + // Set initial value + await counter.reset() + await counter.increment(10) + + // Decrement + const result = await counter.decrement(3) + expect(result).toBe(7) + }) + + test('reset clears value via RPC', async () => { + const id = env.COUNTER.idFromName('rpc-counter-3') + const counter = env.COUNTER.get(id) + + // Set value and reset + await counter.increment(100) + await counter.reset() + + const value = await counter.getValue() + expect(value).toBe(0) + }) + }) + + describe('RateLimiter DO (cross-worker)', () => { + test('can get DO stub via RATE_LIMITER binding', async () => { + const id = env.RATE_LIMITER.idFromName('test-limiter') + const stub = env.RATE_LIMITER.get(id) + expect(stub).toBeDefined() + }) + + test('allows requests under limit via RPC', async () => { + const id = env.RATE_LIMITER.idFromName('rpc-limiter-1') + const limiter = env.RATE_LIMITER.get(id) + + // Reset first + await limiter.reset() + + // First request should be allowed + const limited1 = await limiter.checkLimit(5, 60000) + expect(limited1).toBe(false) + + // Second request should be allowed + const limited2 = await limiter.checkLimit(5, 60000) + expect(limited2).toBe(false) + + // Check remaining + const remaining = await limiter.getRemaining(5) + expect(remaining).toBe(3) + }) + + test('blocks requests over limit via RPC', async () => { + const id = env.RATE_LIMITER.idFromName('rpc-limiter-2') + const limiter = env.RATE_LIMITER.get(id) + + // Reset first + await limiter.reset() + + // Exhaust limit + for (let i = 0; i < 3; i++) { + await limiter.checkLimit(3, 60000) + } + + // Next request should be blocked + const limited = await limiter.checkLimit(3, 60000) + expect(limited).toBe(true) + + const remaining = await limiter.getRemaining(3) + expect(remaining).toBe(0) + }) + }) +}) + +// ----------------------------------------------------------------------------- +// Fetch Handler Integration Tests +// ----------------------------------------------------------------------------- + +describe('Case 3: Fetch Handler Routes', () => { + describe('Root route', () => { + test('GET / returns welcome message', async () => { + const request = new Request('http://localhost/') + const response = await fetch(request) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Case 3: Cross-Worker Durable Objects Demo') + }) + }) + + describe('Session routes (local DO)', () => { + test('GET /session/:id/set/:key?value= sets session value', async () => { + const request = new Request('http://localhost/session/test-user/set/name?value=Bob') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { ok: boolean } + expect(data.ok).toBe(true) + }) + + test('GET /session/:id/get/:key gets session value', async () => { + // First set a value + await fetch(new Request('http://localhost/session/get-user/set/color?value=blue')) + + // Then get it + const request = new Request('http://localhost/session/get-user/get/color') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { value: unknown } + expect(data.value).toBe('blue') + }) + + test('GET /session/:id/info returns session metadata', async () => { + // Set some values + await fetch(new Request('http://localhost/session/info-user/set/a?value=1')) + await fetch(new Request('http://localhost/session/info-user/set/b?value=2')) + + const request = new Request('http://localhost/session/info-user/info') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { itemCount: number; createdAt: number } + expect(data.itemCount).toBe(2) + }) + }) + + describe('Tracker routes (local DO)', () => { + test('GET /track/:path tracks a request', async () => { + const request = new Request('http://localhost/track/api-endpoint') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { total: number; pathCount: number } + expect(data.total).toBeGreaterThanOrEqual(1) + }) + + test('GET /stats returns all path statistics', async () => { + // Track some requests + await fetch(new Request('http://localhost/track/route-a')) + await fetch(new Request('http://localhost/track/route-a')) + await fetch(new Request('http://localhost/track/route-b')) + + const request = new Request('http://localhost/stats') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as Record + expect(data['route-a']).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Counter routes (cross-worker DO)', () => { + test('GET /counter/:name/value returns counter value', async () => { + // First set via direct RPC + const id = env.COUNTER.idFromName('fetch-value-test') + const counter = env.COUNTER.get(id) + await counter.reset() + await counter.increment(42) + + // Then fetch via route + const request = new Request('http://localhost/counter/fetch-value-test/value') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { value: number } + expect(data.value).toBe(42) + }) + + test('GET /counter/:name/increment increments counter', async () => { + const id = env.COUNTER.idFromName('fetch-increment') + const counter = env.COUNTER.get(id) + await counter.reset() + + const request = new Request('http://localhost/counter/fetch-increment/increment') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { value: number } + expect(data.value).toBe(1) + + // Verify via direct RPC + const value = await counter.getValue() + expect(value).toBe(1) + }) + }) + + describe('RateLimiter routes (cross-worker DO)', () => { + test('GET /ratelimit/:key returns rate limit status', async () => { + const id = env.RATE_LIMITER.idFromName('fetch-limit') + const limiter = env.RATE_LIMITER.get(id) + await limiter.reset() + + const request = new Request('http://localhost/ratelimit/fetch-limit') + const response = await fetch(request) + + expect(response.status).toBe(200) + const data = (await response.json()) as { ok: boolean; remaining: number } + expect(data.ok).toBe(true) + expect(data.remaining).toBe(9) // 10 - 1 = 9 + }) + }) + + describe('Error handling', () => { + test('unknown route returns 404', async () => { + const request = new Request('http://localhost/unknown') + const response = await fetch(request) + + expect(response.status).toBe(404) + }) + }) +}) diff --git a/cases/case3/tsconfig.json b/cases/case3/tsconfig.json new file mode 100644 index 0000000..e3feda7 --- /dev/null +++ b/cases/case3/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "devflare.config.ts", + "src/**/*", + "tests/**/*", + "do-service/**/*" + ] +} diff --git a/cases/case5/devflare.config.ts b/cases/case5/devflare.config.ts new file mode 100644 index 0000000..32227b2 --- /dev/null +++ b/cases/case5/devflare.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, ref } from 'devflare/config' + +// Reference the math-service worker's config +// Returns a synchronous proxy - no await needed +const mathWorker = ref(() => import('./math-service/devflare.config')) + +export default defineConfig({ + name: 'case5-gateway', + compatibilityDate: '2026-04-26', + + bindings: { + // Service bindings to other workers (RPC-style) + services: { + // Default worker.ts export (transformed to WorkerEntrypoint) + MATH_SERVICE: mathWorker.worker, + + // Named entrypoint (class extending WorkerEntrypoint in ep.admin.ts) + ADMIN: mathWorker.worker('AdminEntrypoint') + } + } +}) diff --git a/cases/case5/env.d.ts b/cases/case5/env.d.ts new file mode 100644 index 0000000..7486ad7 --- /dev/null +++ b/cases/case5/env.d.ts @@ -0,0 +1,18 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { MathServiceInterface } from './src/math-service.types' +import type { AdminEntrypointInterface } from './src/math-service.types' + +declare global { + interface DevflareEnv { + MATH_SERVICE: MathServiceInterface + ADMIN: AdminEntrypointInterface + } +} + +/** + * Named entrypoints discovered from ep.*.ts files. + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = 'AdminEntrypoint' diff --git a/cases/case5/math-service/devflare.config.ts b/cases/case5/math-service/devflare.config.ts new file mode 100644 index 0000000..0e7be58 --- /dev/null +++ b/cases/case5/math-service/devflare.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'devflare/config' +import type { Entrypoints } from './env' + +// Use defineConfig() for type-safe entrypoint references +// Run `devflare types` to generate the Entrypoints type in env.d.ts +export default defineConfig({ + name: 'math-worker', + compatibilityDate: '2026-04-26', + + // worker.ts is at root (not in src/), so we must specify it + files: { + fetch: 'worker.ts' + } + // Note: entrypoints are auto-discovered from **/ep.*.{ts,js} files +}) diff --git a/cases/case5/math-service/env.d.ts b/cases/case5/math-service/env.d.ts new file mode 100644 index 0000000..4fff2f1 --- /dev/null +++ b/cases/case5/math-service/env.d.ts @@ -0,0 +1,20 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +declare global { + interface DevflareEnv { + } +} + +declare module 'devflare/test' { + interface DevflareEnv { + } +} + +/** + * Named entrypoints discovered from ep.*.ts files. + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = 'AdminEntrypoint' + +export { } diff --git a/cases/case5/math-service/ep.admin.ts b/cases/case5/math-service/ep.admin.ts new file mode 100644 index 0000000..5488961 --- /dev/null +++ b/cases/case5/math-service/ep.admin.ts @@ -0,0 +1,64 @@ +// ============================================================================= +// Case 5: Admin Entrypoint (Named WorkerEntrypoint Pattern) +// ============================================================================= +// This file demonstrates the named entrypoint pattern using `ep.*.ts`: +// - Export a class extending WorkerEntrypoint +// - The class name becomes the entrypoint identifier +// - Referenced via ref().worker('AdminEntrypoint') in other workers +// +// Naming Convention: +// ep.*.ts โ€” Worker Entrypoints (classes extending WorkerEntrypoint) +// do.*.ts โ€” Durable Objects (classes extending DurableObject) +// wf.*.ts โ€” Workflows (classes extending Workflow) +// worker.ts โ€” Default worker export (transformed to WorkerEntrypoint) +// ============================================================================= + +import { WorkerEntrypoint } from 'cloudflare:workers' + +/** + * Admin-only RPC methods for privileged operations. + * Demonstrates a named entrypoint alongside the default worker.ts. + */ +export class AdminEntrypoint extends WorkerEntrypoint { + /** + * Reset all statistics (admin operation) + */ + async resetStats(): Promise<{ success: boolean; timestamp: number }> { + // In a real scenario, this would clear cached stats, reset counters, etc. + return { + success: true, + timestamp: Date.now() + } + } + + /** + * Get service health status (admin operation) + */ + async getHealth(): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy' + uptime: number + version: string + }> { + return { + status: 'healthy', + uptime: Date.now() / 1000, // Simulated uptime in seconds + version: '1.0.0' + } + } + + /** + * Run diagnostics (admin operation) + */ + async runDiagnostics(): Promise<{ + memoryUsage: number + cpuUsage: number + requestCount: number + }> { + // Simulated diagnostics + return { + memoryUsage: Math.random() * 100, + cpuUsage: Math.random() * 50, + requestCount: Math.floor(Math.random() * 1000) + } + } +} diff --git a/cases/case5/math-service/worker.ts b/cases/case5/math-service/worker.ts new file mode 100644 index 0000000..7575c4a --- /dev/null +++ b/cases/case5/math-service/worker.ts @@ -0,0 +1,59 @@ +// ============================================================================= +// Case 5: Math Service Worker (Worker Entrypoint Pattern) +// ============================================================================= +// This file demonstrates the new worker.ts pattern: +// - Export multiple functions that become RPC methods +// - devflare transforms this into a WorkerEntrypoint class at build time +// +// The resulting class exposes each exported function as an RPC method +// that can be called via service bindings from other workers. +// ============================================================================= + +import type { StatsResult } from '../src/math-service.types' + +/** + * RPC method: Add two numbers + */ +export function add(a: number, b: number): number { + return a + b +} + +/** + * RPC method: Multiply two numbers + */ +export function multiply(a: number, b: number): number { + return a * b +} + +/** + * RPC method: Calculate the nth Fibonacci number + */ +export function fibonacci(n: number): number { + if (n <= 1) return n + let a = 0 + let b = 1 + for (let i = 2; i <= n; i++) { + const temp = a + b + a = b + b = temp + } + return b +} + +/** + * RPC method: Calculate statistics for an array of numbers + */ +export function calculateStats(numbers: number[]): StatsResult { + if (numbers.length === 0) { + return { count: 0, sum: 0, mean: 0, min: 0, max: 0 } + } + + const sum = numbers.reduce((acc, n) => acc + n, 0) + return { + count: numbers.length, + sum, + mean: sum / numbers.length, + min: Math.min(...numbers), + max: Math.max(...numbers) + } +} diff --git a/cases/case5/package.json b/cases/case5/package.json new file mode 100644 index 0000000..9a54d6b --- /dev/null +++ b/cases/case5/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case5-multi-worker", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case5/src/fetch.ts b/cases/case5/src/fetch.ts new file mode 100644 index 0000000..825800e --- /dev/null +++ b/cases/case5/src/fetch.ts @@ -0,0 +1,75 @@ +// ============================================================================= +// Case 5: Multi-Worker & Service Bindings (RPC) - Gateway +// ============================================================================= +// Demonstrates devflare's patterns for multi-worker setups with RPC: +// - Service bindings for worker-to-worker RPC calls +// - Type-safe method invocation via WorkerEntrypoint pattern +// +// See: https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/ +// ============================================================================= + +/** + * Gateway worker that routes requests and calls RPC methods on other workers + */ +export default async function fetch( + request: Request, + env: DevflareEnv, + ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json({ + name: 'Case 5: RPC Gateway', + description: 'Demonstrates Worker RPC via WorkerEntrypoint', + routes: [ + 'GET /add?a=1&b=2', + 'GET /multiply?a=3&b=4', + 'GET /fibonacci?n=10', + 'GET /stats?numbers=1,2,3,4,5' + ] + }) + } + + // Route: GET /add?a=X&b=Y + // Calls MATH_SERVICE.add(a, b) via RPC + if (url.pathname === '/add') { + const a = parseFloat(url.searchParams.get('a') ?? '0') + const b = parseFloat(url.searchParams.get('b') ?? '0') + + const result = await env.MATH_SERVICE.add(a, b) + return Response.json({ operation: 'add', a, b, result }) + } + + // Route: GET /multiply?a=X&b=Y + // Calls MATH_SERVICE.multiply(a, b) via RPC + if (url.pathname === '/multiply') { + const a = parseFloat(url.searchParams.get('a') ?? '0') + const b = parseFloat(url.searchParams.get('b') ?? '0') + + const result = await env.MATH_SERVICE.multiply(a, b) + return Response.json({ operation: 'multiply', a, b, result }) + } + + // Route: GET /fibonacci?n=X + // Calls MATH_SERVICE.fibonacci(n) via RPC + if (url.pathname === '/fibonacci') { + const n = parseInt(url.searchParams.get('n') ?? '10', 10) + + const result = await env.MATH_SERVICE.fibonacci(n) + return Response.json({ operation: 'fibonacci', n, result }) + } + + // Route: GET /stats?numbers=1,2,3,4,5 + // Calls MATH_SERVICE.calculateStats(numbers) via RPC + if (url.pathname === '/stats') { + const numbersParam = url.searchParams.get('numbers') ?? '' + const numbers = numbersParam.split(',').map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)) + + const stats = await env.MATH_SERVICE.calculateStats(numbers) + return Response.json({ operation: 'stats', numbers, ...stats }) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case5/src/math-service.types.ts b/cases/case5/src/math-service.types.ts new file mode 100644 index 0000000..42d061f --- /dev/null +++ b/cases/case5/src/math-service.types.ts @@ -0,0 +1,79 @@ +// ============================================================================= +// Case 5: Multi-Worker - MathService Interface (RPC Contract) +// ============================================================================= +// Defines the interface for the MathService worker's RPC methods. +// This is the "contract" that both the service and client agree on. +// +// In a real monorepo, this would be in a shared package: +// packages/shared/src/math-service.types.ts +// ============================================================================= + +/** + * Statistics result from calculateStats + */ +export interface StatsResult { + count: number + sum: number + mean: number + min: number + max: number +} + +/** + * Interface for MathService RPC methods + * + * This mirrors the public methods of the MathService WorkerEntrypoint class. + * Service bindings provide this interface at runtime via RPC. + */ +export interface MathServiceInterface { + /** + * Add two numbers + */ + add(a: number, b: number): Promise + + /** + * Multiply two numbers + */ + multiply(a: number, b: number): Promise + + /** + * Calculate the nth Fibonacci number + */ + fibonacci(n: number): Promise + + /** + * Calculate statistics for an array of numbers + */ + calculateStats(numbers: number[]): Promise +} + +/** + * Interface for AdminEntrypoint RPC methods + * + * Admin-only operations for privileged access. + * Referenced via mathWorker.worker('AdminEntrypoint') in config. + */ +export interface AdminEntrypointInterface { + /** + * Reset all statistics + */ + resetStats(): Promise<{ success: boolean; timestamp: number }> + + /** + * Get service health status + */ + getHealth(): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy' + uptime: number + version: string + }> + + /** + * Run diagnostics + */ + runDiagnostics(): Promise<{ + memoryUsage: number + cpuUsage: number + requestCount: number + }> +} diff --git a/cases/case5/src/math-service.worker.ts b/cases/case5/src/math-service.worker.ts new file mode 100644 index 0000000..945ec9b --- /dev/null +++ b/cases/case5/src/math-service.worker.ts @@ -0,0 +1,67 @@ +// ============================================================================= +// Case 5: Multi-Worker - MathService WorkerEntrypoint +// ============================================================================= +// This is the math service worker that exposes RPC methods via WorkerEntrypoint. +// It runs as a separate worker and is called by the gateway via service binding. +// +// In production, this would be deployed as a separate worker with its own config. +// For testing, we bundle it into the same Miniflare instance using the workers array. +// ============================================================================= + +import { WorkerEntrypoint } from 'cloudflare:workers' +import type { StatsResult } from './math-service.types' + +/** + * MathService WorkerEntrypoint + * + * Extends WorkerEntrypoint to expose RPC methods that other workers can call. + * Each public method becomes an RPC endpoint accessible via service binding. + */ +export class MathService extends WorkerEntrypoint { + /** + * RPC method: Add two numbers + */ + add(a: number, b: number): number { + return a + b + } + + /** + * RPC method: Multiply two numbers + */ + multiply(a: number, b: number): number { + return a * b + } + + /** + * RPC method: Calculate the nth Fibonacci number + */ + fibonacci(n: number): number { + if (n <= 1) return n + let a = 0 + let b = 1 + for (let i = 2; i <= n; i++) { + const temp = a + b + a = b + b = temp + } + return b + } + + /** + * RPC method: Calculate statistics for an array of numbers + */ + calculateStats(numbers: number[]): StatsResult { + if (numbers.length === 0) { + return { count: 0, sum: 0, mean: 0, min: 0, max: 0 } + } + + const sum = numbers.reduce((acc, n) => acc + n, 0) + return { + count: numbers.length, + sum, + mean: sum / numbers.length, + min: Math.min(...numbers), + max: Math.max(...numbers) + } + } +} diff --git a/cases/case5/tests/gateway.test.ts b/cases/case5/tests/gateway.test.ts new file mode 100644 index 0000000..523f07d --- /dev/null +++ b/cases/case5/tests/gateway.test.ts @@ -0,0 +1,133 @@ +// ============================================================================= +// Case 5: Multi-Worker (RPC) - Gateway Tests using devflare test context +// ============================================================================= +// This test demonstrates devflare's standard test pattern for multi-worker RPC. +// +// Pattern: +// - Use createTestContext() which auto-detects service bindings from config +// - Access service bindings via env.MATH_SERVICE (default) and env.ADMIN (named) +// - The math-service worker.ts is automatically bundled and transformed +// - Run `devflare types` to generate typed service bindings in env.d.ts +// +// Naming Conventions: +// worker.ts โ€” Default worker export (transformed to WorkerEntrypoint) +// ep.*.ts โ€” Named entrypoints (classes extending WorkerEntrypoint) +// do.*.ts โ€” Durable Objects (classes extending DurableObject) +// wf.*.ts โ€” Workflows (classes extending Workflow) +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext } from 'devflare/test' +import { env } from 'devflare' + +// Types are generated by `devflare types` in env.d.ts +// The generated types include: +// MATH_SERVICE: MathServiceInterface +// ADMIN: AdminEntrypointInterface + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe('Case 5: Multi-Worker RPC via WorkerEntrypoint', () => { + // ------------------------------------------------------------------------- + // Default Worker Tests (worker.ts โ†’ MATH_SERVICE binding) + // ------------------------------------------------------------------------- + describe('Default worker.ts binding (MATH_SERVICE)', () => { + test('add() returns sum of two numbers', async () => { + const result = await env.MATH_SERVICE.add(5, 3) + expect(result).toBe(8) + }) + + test('add() handles negative numbers', async () => { + const result = await env.MATH_SERVICE.add(-10, 4) + expect(result).toBe(-6) + }) + + test('add() handles decimals', async () => { + const result = await env.MATH_SERVICE.add(1.5, 2.5) + expect(result).toBe(4) + }) + + test('multiply() returns product of two numbers', async () => { + const result = await env.MATH_SERVICE.multiply(6, 7) + expect(result).toBe(42) + }) + + test('multiply() handles zero', async () => { + const result = await env.MATH_SERVICE.multiply(100, 0) + expect(result).toBe(0) + }) + + test('fibonacci() calculates correct value', async () => { + const result = await env.MATH_SERVICE.fibonacci(10) + expect(result).toBe(55) // fib(10) = 55 + }) + + test('fibonacci() handles base cases', async () => { + expect(await env.MATH_SERVICE.fibonacci(0)).toBe(0) + expect(await env.MATH_SERVICE.fibonacci(1)).toBe(1) + }) + + test('calculateStats() returns correct statistics', async () => { + const stats = await env.MATH_SERVICE.calculateStats([1, 2, 3, 4, 5]) + expect(stats.count).toBe(5) + expect(stats.sum).toBe(15) + expect(stats.mean).toBe(3) + expect(stats.min).toBe(1) + expect(stats.max).toBe(5) + }) + + test('calculateStats() handles empty array', async () => { + const stats = await env.MATH_SERVICE.calculateStats([]) + expect(stats.count).toBe(0) + expect(stats.sum).toBe(0) + }) + + test('calculateStats() handles single number', async () => { + const stats = await env.MATH_SERVICE.calculateStats([42]) + expect(stats.count).toBe(1) + expect(stats.sum).toBe(42) + expect(stats.mean).toBe(42) + expect(stats.min).toBe(42) + expect(stats.max).toBe(42) + }) + }) + + // ------------------------------------------------------------------------- + // Named Entrypoint Tests (ep.admin.ts โ†’ ADMIN binding) + // ------------------------------------------------------------------------- + describe('Named entrypoint binding (ADMIN)', () => { + test('resetStats() returns success', async () => { + const result = await env.ADMIN.resetStats() + expect(result.success).toBe(true) + expect(typeof result.timestamp).toBe('number') + }) + + test('getHealth() returns health status', async () => { + const health = await env.ADMIN.getHealth() + expect(health.status).toBe('healthy') + expect(typeof health.uptime).toBe('number') + expect(health.version).toBe('1.0.0') + }) + + test('runDiagnostics() returns metrics', async () => { + const diagnostics = await env.ADMIN.runDiagnostics() + expect(typeof diagnostics.memoryUsage).toBe('number') + expect(typeof diagnostics.cpuUsage).toBe('number') + expect(typeof diagnostics.requestCount).toBe('number') + }) + }) +}) diff --git a/cases/case5/tsconfig.json b/cases/case5/tsconfig.json new file mode 100644 index 0000000..ac06640 --- /dev/null +++ b/cases/case5/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*", + "math-service/**/*" + ] +} diff --git a/cases/case6/devflare.config.ts b/cases/case6/devflare.config.ts new file mode 100644 index 0000000..10493f7 --- /dev/null +++ b/cases/case6/devflare.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case6-queues-crons', + compatibilityDate: '2026-04-26', + + bindings: { + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue', + maxBatchSize: 10, + maxRetries: 3 + } + ] + }, + kv: { + RESULTS: 'results-kv-id' + } + }, + + triggers: { + crons: ['0 */6 * * *', '0 0 * * 1'] + } +}) diff --git a/cases/case6/env.d.ts b/cases/case6/env.d.ts new file mode 100644 index 0000000..3aa54cd --- /dev/null +++ b/cases/case6/env.d.ts @@ -0,0 +1,17 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace, Queue } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + RESULTS: KVNamespace + TASK_QUEUE: Queue + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case6/package.json b/cases/case6/package.json new file mode 100644 index 0000000..139fe3b --- /dev/null +++ b/cases/case6/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case6-queues-crons", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case6/src/fetch.ts b/cases/case6/src/fetch.ts new file mode 100644 index 0000000..396613d --- /dev/null +++ b/cases/case6/src/fetch.ts @@ -0,0 +1,58 @@ +// ============================================================================= +// Case 6: Queues & Crons - Fetch Handler +// ============================================================================= +// Demonstrates devflare's file-based patterns: +// - src/fetch.ts for HTTP handler +// - src/queue.ts for queue consumer +// - src/scheduled.ts for cron handlers +// ============================================================================= + +import { env } from 'devflare' +import type { Task } from './lib/types' + +/** + * HTTP handler - accepts tasks and sends to queue + */ +export default async function fetch( + request: Request +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json({ + name: 'Case 6: Queues & Crons', + endpoints: ['POST /tasks', 'GET /results/:id'] + }) + } + + // Route: POST /tasks - Add task to queue + if (url.pathname === '/tasks' && request.method === 'POST') { + const body = await request.json<{ type: Task['type']; data: Record }>() + + const task: Task = { + id: crypto.randomUUID(), + type: body.type, + data: body.data, + createdAt: Date.now() + } + + await env.TASK_QUEUE.send(task) + + return Response.json({ queued: true, taskId: task.id }, { status: 202 }) + } + + // Route: GET /results/:id - Get task result + if (url.pathname.startsWith('/results/')) { + const taskId = url.pathname.slice(9) + const result = await env.RESULTS.get(`result:${taskId}`, 'json') + + if (!result) { + return Response.json({ status: 'pending' }) + } + + return Response.json(result) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case6/src/lib/tasks.ts b/cases/case6/src/lib/tasks.ts new file mode 100644 index 0000000..80e20c9 --- /dev/null +++ b/cases/case6/src/lib/tasks.ts @@ -0,0 +1,53 @@ +// ============================================================================= +// Case 6: Queues & Crons - Task Processing Logic +// ============================================================================= +// Business logic for processing tasks, can be tested independently +// ============================================================================= + +import { env } from 'devflare' +import type { Task } from './types' + +/** + * Process a task based on its type + */ +export async function processTask(task: Task): Promise { + switch (task.type) { + case 'process': + return { processed: true, data: task.data } + + case 'cleanup': + return { cleaned: true, items: 0 } + + case 'notify': + return { notified: true, recipients: task.data.recipients } + + default: + throw new Error(`Unknown task type: ${(task as Task).type}`) + } +} + +/** + * Cleanup old results from KV + */ +export async function cleanupOldResults(): Promise { + const list = await env.RESULTS.list({ prefix: 'result:' }) + + // In real implementation, check timestamps and delete old entries + // For demo purposes, we just list the keys + for (const key of list.keys) { + // Could check timestamp metadata and delete if old + // await env.RESULTS.delete(key.name) + } +} + +/** + * Generate weekly report + */ +export async function generateWeeklyReport(): Promise { + const list = await env.RESULTS.list({ prefix: 'result:' }) + + await env.RESULTS.put('report:weekly', JSON.stringify({ + totalTasks: list.keys.length, + generatedAt: Date.now() + })) +} diff --git a/cases/case6/src/lib/types.ts b/cases/case6/src/lib/types.ts new file mode 100644 index 0000000..2fda727 --- /dev/null +++ b/cases/case6/src/lib/types.ts @@ -0,0 +1,15 @@ +// ============================================================================= +// Case 6: Queues & Crons - Shared Types +// ============================================================================= + +export interface Task { + id: string + type: 'process' | 'cleanup' | 'notify' + data: Record + createdAt: number +} + +export interface Env { + TASK_QUEUE: Queue + RESULTS: KVNamespace +} diff --git a/cases/case6/src/queue.ts b/cases/case6/src/queue.ts new file mode 100644 index 0000000..d7a6b50 --- /dev/null +++ b/cases/case6/src/queue.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// Case 6: Queues & Crons - Queue Consumer Handler +// ============================================================================= +// Queue consumer - processes batched messages +// ============================================================================= + +import { env } from 'devflare' +import type { QueueEvent } from 'devflare/runtime' +import type { Task } from './lib/types' +import { processTask } from './lib/tasks' + +/** + * Queue consumer handler + * Processes batched messages from TASK_QUEUE + */ +export default async function queue( + event: QueueEvent +): Promise { + for (const message of event.messages) { + const task = message.body + + try { + const result = await processTask(task) + + // Store result + await env.RESULTS.put(`result:${task.id}`, JSON.stringify({ + status: 'completed', + result, + processedAt: Date.now() + })) + + // Acknowledge message + message.ack() + } catch (error) { + // Retry on failure + message.retry() + } + } +} diff --git a/cases/case6/src/scheduled.ts b/cases/case6/src/scheduled.ts new file mode 100644 index 0000000..850b028 --- /dev/null +++ b/cases/case6/src/scheduled.ts @@ -0,0 +1,29 @@ +// ============================================================================= +// Case 6: Queues & Crons - Scheduled Handler +// ============================================================================= +// Scheduled handler - runs on cron triggers +// ============================================================================= + +import { env } from 'devflare' +import type { ScheduledEvent } from 'devflare/runtime' +import { cleanupOldResults, generateWeeklyReport } from './lib/tasks' + +/** + * Scheduled handler + * Runs on cron triggers defined in devflare.config.ts + */ +export default async function scheduled( + event: ScheduledEvent +): Promise { + const cron = event.cron + + // Every 6 hours - cleanup old results + if (cron === '0 */6 * * *') { + event.ctx.waitUntil(cleanupOldResults()) + } + + // Every Monday at midnight - weekly report + if (cron === '0 0 * * 1') { + event.ctx.waitUntil(generateWeeklyReport()) + } +} diff --git a/cases/case6/tests/queues.test.ts b/cases/case6/tests/queues.test.ts new file mode 100644 index 0000000..00510cf --- /dev/null +++ b/cases/case6/tests/queues.test.ts @@ -0,0 +1,217 @@ +// ============================================================================= +// Case 6: Queues & Crons - Tests with Real Miniflare +// ============================================================================= +// Demonstrates testing with REAL KV bindings via createTestContext. +// Pure logic tests don't need bindings. +// Queue handler is tested via cf.queue.trigger() for direct handler invocation. +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext, cf } from 'devflare/test' +import { env } from 'devflare' +import { processTask } from '../src/lib/tasks' +import type { Task } from '../src/lib/types' + +// ----------------------------------------------------------------------------- +// Test Setup +// ----------------------------------------------------------------------------- + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +describe('Case 6: Queues & Crons', () => { + // ------------------------------------------------------------------------- + // Pure Logic Tests (no bindings needed) + // ------------------------------------------------------------------------- + describe('processTask (pure logic)', () => { + test('processes "process" task type', async () => { + const task: Task = { + id: 'task-1', + type: 'process', + data: { value: 42 }, + createdAt: Date.now() + } + + const result = await processTask(task) + + expect(result).toEqual({ processed: true, data: { value: 42 } }) + }) + + test('processes "cleanup" task type', async () => { + const task: Task = { + id: 'task-2', + type: 'cleanup', + data: {}, + createdAt: Date.now() + } + + const result = await processTask(task) + + expect(result).toEqual({ cleaned: true, items: 0 }) + }) + + test('processes "notify" task type', async () => { + const task: Task = { + id: 'task-3', + type: 'notify', + data: { recipients: ['user@example.com'] }, + createdAt: Date.now() + } + + const result = await processTask(task) + + expect(result).toEqual({ + notified: true, + recipients: ['user@example.com'] + }) + }) + + test('throws for unknown task type', async () => { + const task = { + id: 'task-4', + type: 'unknown' as Task['type'], + data: {}, + createdAt: Date.now() + } + + await expect(processTask(task)).rejects.toThrow( + 'Unknown task type: unknown' + ) + }) + }) + + // ------------------------------------------------------------------------- + // Queue Handler Tests with cf.queue.trigger() + // Uses the real queue handler + real KV bindings + // ------------------------------------------------------------------------- + describe('queue handler with cf.queue.trigger()', () => { + test('processes task and stores result in real KV', async () => { + const task: Task = { + id: 'queue-test-1', + type: 'process', + data: { value: 100 }, + createdAt: Date.now() + } + + const result = await cf.queue.trigger([ + { id: 'msg-1', body: task } + ]) + + // Verify message was acknowledged + expect(result.acked).toContain('msg-1') + expect(result.total).toBe(1) + + // Verify result was stored in REAL KV + const stored = await env.RESULTS.get('result:queue-test-1', 'json') as { + status: string + result: { processed: boolean; data: { value: number } } + } | null + expect(stored).toBeDefined() + expect(stored?.status).toBe('completed') + expect(stored?.result?.processed).toBe(true) + }) + + test('processes multiple tasks in batch', async () => { + const tasks: Task[] = [ + { id: 'batch-1', type: 'process', data: { x: 1 }, createdAt: Date.now() }, + { id: 'batch-2', type: 'cleanup', data: {}, createdAt: Date.now() }, + { id: 'batch-3', type: 'notify', data: { recipients: ['a@b.com'] }, createdAt: Date.now() } + ] + + const result = await cf.queue.trigger( + tasks.map((task, i) => ({ id: `batch-msg-${i}`, body: task })) + ) + + expect(result.acked).toHaveLength(3) + expect(result.total).toBe(3) + + // Verify all results stored + for (const task of tasks) { + const stored = await env.RESULTS.get(`result:${task.id}`, 'json') + expect(stored).toBeDefined() + } + }) + + test('retries task on processing error', async () => { + const task = { + id: 'error-task', + type: 'unknown' as Task['type'], + data: {}, + createdAt: Date.now() + } + + const result = await cf.queue.trigger([ + { id: 'error-msg', body: task } + ]) + + // Unknown task type throws, so message should be retried + expect(result.retried).toContain('error-msg') + }) + }) + + // ------------------------------------------------------------------------- + // Integration Tests with Real Miniflare KV + // ------------------------------------------------------------------------- + describe('fetch handler with Real Miniflare', () => { + test('POST /tasks queues task', async () => { + // Use cf.worker.post to call the fetch handler + const response = await cf.worker.post('/tasks', { + type: 'process', + data: { x: 1 } + }) + + expect(response.status).toBe(202) + + const data = await response.json() as { queued: boolean; taskId: string } + expect(data.queued).toBe(true) + expect(data.taskId).toBeDefined() + }) + + test('GET /results/:id returns result from REAL KV', async () => { + // Pre-populate REAL KV + await env.RESULTS.put( + 'result:task-123', + JSON.stringify({ status: 'completed', result: { done: true } }) + ) + + const response = await cf.worker.get('/results/task-123') + + expect(response.status).toBe(200) + const data = await response.json() as { status: string } + expect(data.status).toBe('completed') + }) + + test('GET /results/:id returns pending for unknown task', async () => { + const response = await cf.worker.get('/results/unknown') + + expect(response.status).toBe(200) + const data = await response.json() as { status: string } + expect(data.status).toBe('pending') + }) + }) + + // ------------------------------------------------------------------------- + // Real KV operations via unified env + // ------------------------------------------------------------------------- + describe('Real KV operations', () => { + test('put/get/delete work with real KV', async () => { + await env.RESULTS.put('key1', 'value1') + expect(await env.RESULTS.get('key1')).toBe('value1') + + await env.RESULTS.delete('key1') + expect(await env.RESULTS.get('key1')).toBeNull() + }) + + test('JSON storage works with real KV', async () => { + await env.RESULTS.put('json-key', JSON.stringify({ foo: 'bar' })) + const obj = await env.RESULTS.get('json-key', 'json') + + expect(obj).toEqual({ foo: 'bar' }) + }) + }) +}) diff --git a/cases/case6/tsconfig.json b/cases/case6/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case6/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case7/devflare.config.ts b/cases/case7/devflare.config.ts new file mode 100644 index 0000000..8419eb5 --- /dev/null +++ b/cases/case7/devflare.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case7-edge-cases', + compatibilityDate: '2026-04-26', + + bindings: { + kv: { + CACHE: 'cache-kv-id' + } + } +}) diff --git a/cases/case7/env.d.ts b/cases/case7/env.d.ts new file mode 100644 index 0000000..eeb793c --- /dev/null +++ b/cases/case7/env.d.ts @@ -0,0 +1,16 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +import type { KVNamespace } from '@cloudflare/workers-types' + +declare global { + interface DevflareEnv { + CACHE: KVNamespace + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case7/package.json b/cases/case7/package.json new file mode 100644 index 0000000..aed4a0e --- /dev/null +++ b/cases/case7/package.json @@ -0,0 +1,17 @@ +{ + "name": "@devflare/case7-edge-cases", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "miniflare": "^4.20260317.2", + "typescript": "^5.7.2" + } +} diff --git a/cases/case7/src/fetch.ts b/cases/case7/src/fetch.ts new file mode 100644 index 0000000..73fa5eb --- /dev/null +++ b/cases/case7/src/fetch.ts @@ -0,0 +1,145 @@ +// ============================================================================= +// Case 7: Edge Cases & Advanced Patterns - Fetch Handler +// ============================================================================= +// Demonstrates error handling, streaming, and advanced response patterns +// Using devflare's file-based patterns +// ============================================================================= + +interface Env { + CACHE: KVNamespace +} + +/** + * Main fetch handler + * Demonstrates edge cases and advanced patterns + */ +export default async function fetch( + request: Request, + env: Env, + ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json({ + name: 'Case 7: Edge Cases', + endpoints: [ + '/stream', + '/error', + '/error-handled', + '/timeout?ms=1000', + 'POST /echo', + '/headers', + '/redirect?to=/', + '/cache-api' + ] + }) + } + + // Route: GET /stream - Streaming response + if (url.pathname === '/stream') { + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`)) + await new Promise((r) => setTimeout(r, 100)) + } + controller.close() + } + }) + + return new Response(stream, { + headers: { 'Content-Type': 'text/plain' } + }) + } + + // Route: GET /error - Intentional error + if (url.pathname === '/error') { + throw new Error('Intentional error for testing') + } + + // Route: GET /error-handled - Handled error + if (url.pathname === '/error-handled') { + try { + throw new Error('Handled error') + } catch (error) { + return Response.json( + { + error: 'Something went wrong', + message: error instanceof Error ? error.message : 'Unknown' + }, + { status: 500 } + ) + } + } + + // Route: GET /timeout - Simulated timeout + if (url.pathname === '/timeout') { + const timeout = parseInt(url.searchParams.get('ms') || '5000') + await new Promise((r) => setTimeout(r, timeout)) + return new Response('Completed after delay') + } + + // Route: POST /echo - Echo request body + if (url.pathname === '/echo' && request.method === 'POST') { + const body = await request.text() + return new Response(body, { + headers: { + 'Content-Type': request.headers.get('Content-Type') || 'text/plain' + } + }) + } + + // Route: GET /headers - Return all headers + if (url.pathname === '/headers') { + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + return Response.json({ headers }) + } + + // Route: GET /redirect - Redirect example + if (url.pathname === '/redirect') { + const target = url.searchParams.get('to') || '/' + return Response.redirect(new URL(target, request.url).toString(), 302) + } + + // Route: GET /cache-api - Using Cache API + if (url.pathname === '/cache-api') { + const cacheKey = new Request(request.url) + // caches.default is Cloudflare-specific, cast for type safety + const cache = (caches as unknown as { default: Cache }).default + + // Try cache first + let response = await cache.match(cacheKey) + if (response) { + return new Response(response.body, { + headers: { + ...Object.fromEntries(response.headers), + 'X-Cache': 'HIT' + } + }) + } + + // Generate response + response = Response.json({ + generated: Date.now(), + cached: true + }) + + // Cache for 60 seconds + response.headers.set('Cache-Control', 'max-age=60') + ctx.waitUntil(cache.put(cacheKey, response.clone())) + + return new Response(response.body, { + headers: { + ...Object.fromEntries(response.headers), + 'X-Cache': 'MISS' + } + }) + } + + return new Response('Not found', { status: 404 }) +} diff --git a/cases/case7/tests/edge-cases.test.ts b/cases/case7/tests/edge-cases.test.ts new file mode 100644 index 0000000..3ff883f --- /dev/null +++ b/cases/case7/tests/edge-cases.test.ts @@ -0,0 +1,97 @@ +// ============================================================================= +// Case 7: Edge Cases - Tests with Real Miniflare +// ============================================================================= +// Tests edge case handlers using REAL Miniflare KV via createTestContext +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { createTestContext, cf } from 'devflare/test' +import { env } from 'devflare' + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +describe('Case 7: Edge Cases & Advanced Patterns', () => { + describe('error handling', () => { + test('/error-handled returns 500 with error info', async () => { + const response = await cf.worker.get('/error-handled') + + expect(response.status).toBe(500) + const data = await response.json() as { error: string; message: string } + expect(data.error).toBe('Something went wrong') + expect(data.message).toBe('Handled error') + }) + }) + + describe('echo endpoint', () => { + test('POST /echo returns request body', async () => { + const response = await cf.worker.post('/echo', 'Hello World', { + 'Content-Type': 'text/plain' + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello World') + }) + + test('POST /echo preserves content type', async () => { + const response = await cf.worker.post( + '/echo', + JSON.stringify({ key: 'value' }), + { 'Content-Type': 'application/json' } + ) + + expect(response.headers.get('Content-Type')).toBe('application/json') + }) + }) + + describe('headers endpoint', () => { + test('GET /headers returns request headers', async () => { + const response = await cf.worker.get('/headers', { + 'X-Custom-Header': 'test-value', + 'Accept': 'application/json' + }) + + expect(response.status).toBe(200) + const data = await response.json() as { headers: Record } + expect(data.headers['x-custom-header']).toBe('test-value') + }) + }) + + describe('redirect endpoint', () => { + test('GET /redirect returns 302', async () => { + const response = await cf.worker.get('/redirect?to=/target') + + expect(response.status).toBe(302) + expect(response.headers.get('Location')).toBe('http://localhost/target') + }) + }) + + describe('streaming', () => { + test('GET /stream returns readable stream', async () => { + const response = await cf.worker.get('/stream') + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + + const text = await response.text() + expect(text).toContain('chunk 0') + expect(text).toContain('chunk 4') + }) + }) + + describe('index route', () => { + test('GET / returns endpoint list', async () => { + const response = await cf.worker.get('/') + + expect(response.status).toBe(200) + const data = await response.json() as { name: string; endpoints: string[] } + expect(data.name).toBe('Case 7: Edge Cases') + expect(data.endpoints).toContain('/stream') + }) + }) +}) diff --git a/cases/case7/tsconfig.json b/cases/case7/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case7/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case8/devflare.config.ts b/cases/case8/devflare.config.ts new file mode 100644 index 0000000..c0e2943 --- /dev/null +++ b/cases/case8/devflare.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case8-file-routing', + compatibilityDate: '2026-04-26', + + files: { + routes: { + dir: 'src/routes' + } + } +}) diff --git a/cases/case8/env.d.ts b/cases/case8/env.d.ts new file mode 100644 index 0000000..5c9c90d --- /dev/null +++ b/cases/case8/env.d.ts @@ -0,0 +1,13 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +declare global { + interface DevflareEnv { + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case8/package.json b/cases/case8/package.json new file mode 100644 index 0000000..98c94ff --- /dev/null +++ b/cases/case8/package.json @@ -0,0 +1,16 @@ +{ + "name": "@devflare/case8-file-routing", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case8/src/fetch.ts b/cases/case8/src/fetch.ts new file mode 100644 index 0000000..ff5b1b3 --- /dev/null +++ b/cases/case8/src/fetch.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// Case 8: File-Based Routing - Fetch Handler +// ============================================================================= +// Demonstrates route-module organization with a manual router: +// - src/fetch.ts as main entry point +// - src/routes/ folder for route-module organization +// - src/lib/ for shared utilities like Router +// ============================================================================= + +import type { FetchEvent } from 'devflare/runtime' +import { Router } from './lib/router' + +// Import route handlers +import * as indexRoutes from './routes/index' +import * as userRoutes from './routes/users/[id]' +import * as apiRoutes from './routes/api/[...path]' + +// Build router from file-based routes +const router = new Router() + +// Register routes manually. +// Devflare does not currently auto-generate a built-in route tree dispatcher from files.routes. +router.get('/', indexRoutes.GET) +router.get('/users/:id', userRoutes.GET) +router.put('/users/:id', userRoutes.PUT) +router.delete('/users/:id', userRoutes.DELETE) +router.get('/api/[...path]', apiRoutes.GET) + +/** + * Main fetch handler + * Routes requests to file-based route handlers + */ + +export async function fetch({ request }: FetchEvent): Promise { + return router.handle(request) +} + +// Re-export Router for testing +export { Router } from './lib/router' diff --git a/cases/case8/src/lib/router.ts b/cases/case8/src/lib/router.ts new file mode 100644 index 0000000..a0c56fd --- /dev/null +++ b/cases/case8/src/lib/router.ts @@ -0,0 +1,95 @@ +// ============================================================================= +// Case 8: File-Based Routing - Router Utility +// ============================================================================= +// Demonstrates a simple file-based routing pattern +// ============================================================================= + +export type RouteHandler = ( + request: Request, + params: Record +) => Promise | Response + +interface Route { + pattern: RegExp + handler: RouteHandler + method: string +} + +/** + * Simple router class for file-based routing demonstration + */ +export class Router { + private routes: Route[] = [] + + /** + * Add a GET route + */ + get(path: string, handler: RouteHandler): this { + return this.addRoute('GET', path, handler) + } + + /** + * Add a POST route + */ + post(path: string, handler: RouteHandler): this { + return this.addRoute('POST', path, handler) + } + + /** + * Add a PUT route + */ + put(path: string, handler: RouteHandler): this { + return this.addRoute('PUT', path, handler) + } + + /** + * Add a DELETE route + */ + delete(path: string, handler: RouteHandler): this { + return this.addRoute('DELETE', path, handler) + } + + /** + * Add a route with any method + */ + addRoute(method: string, path: string, handler: RouteHandler): this { + const pattern = this.pathToRegex(path) + this.routes.push({ pattern, handler, method }) + return this + } + + /** + * Handle an incoming request + */ + async handle(request: Request): Promise { + const url = new URL(request.url) + + for (const route of this.routes) { + if (route.method !== request.method) continue + + const match = url.pathname.match(route.pattern) + if (match) { + const params = match.groups || {} + return route.handler(request, params) + } + } + + return new Response('Not found', { status: 404 }) + } + + /** + * Convert path pattern to regex + * Supports :param and [...rest] patterns + */ + private pathToRegex(path: string): RegExp { + const pattern = path + // Named params: :id -> (?[^/]+) + .replace(/:(\w+)/g, '(?<$1>[^/]+)') + // Catch-all: [...rest] -> (?.*) + .replace(/\[\.\.\.(.*?)\]/g, '(?<$1>.*)') + // Static segments + .replace(/\//g, '\\/') + + return new RegExp(`^${pattern}$`) + } +} diff --git a/cases/case8/src/routes/api/[...path].ts b/cases/case8/src/routes/api/[...path].ts new file mode 100644 index 0000000..7e50431 --- /dev/null +++ b/cases/case8/src/routes/api/[...path].ts @@ -0,0 +1,18 @@ +// ============================================================================= +// Case 8: File-Based Routing - Route: /api/[...path] +// ============================================================================= + +import type { RouteHandler } from '../../lib/router' + +/** + * Catch-all route for /api/* + */ +export const GET: RouteHandler = async (request, params) => { + const { path } = params + + return Response.json({ + catchAll: true, + path: path || '', + segments: path ? path.split('/').filter(Boolean) : [] + }) +} diff --git a/cases/case8/src/routes/index.ts b/cases/case8/src/routes/index.ts new file mode 100644 index 0000000..f986811 --- /dev/null +++ b/cases/case8/src/routes/index.ts @@ -0,0 +1,12 @@ +// ============================================================================= +// Case 8: File-Based Routing - Route: / +// ============================================================================= + +import type { RouteHandler } from '../lib/router' + +export const GET: RouteHandler = async (request, params) => { + return Response.json({ + name: 'Case 8: File-Based Routing', + message: 'Welcome to the home page' + }) +} diff --git a/cases/case8/src/routes/users/[id].ts b/cases/case8/src/routes/users/[id].ts new file mode 100644 index 0000000..ff77368 --- /dev/null +++ b/cases/case8/src/routes/users/[id].ts @@ -0,0 +1,35 @@ +// ============================================================================= +// Case 8: File-Based Routing - Route: /users/:id +// ============================================================================= + +import type { RouteHandler } from '../../lib/router' + +export const GET: RouteHandler = async (request, params) => { + const { id } = params + + return Response.json({ + user: { + id, + name: `User ${id}`, + email: `user${id}@example.com` + } + }) +} + +export const PUT: RouteHandler = async (request, params) => { + const { id } = params + const body = await request.json() + + return Response.json({ + message: `Updated user ${id}`, + data: body + }) +} + +export const DELETE: RouteHandler = async (request, params) => { + const { id } = params + + return Response.json({ + message: `Deleted user ${id}` + }) +} diff --git a/cases/case8/tests/routing.test.ts b/cases/case8/tests/routing.test.ts new file mode 100644 index 0000000..c552654 --- /dev/null +++ b/cases/case8/tests/routing.test.ts @@ -0,0 +1,110 @@ +// ============================================================================= +// Case 8: File-Based Routing - Tests +// ============================================================================= + +import { describe, test, expect } from 'bun:test' +import { Router } from '../src/lib/router' + +describe('Case 8: File-Based Routing', () => { + describe('Router', () => { + test('matches static routes', async () => { + const router = new Router() + router.get('/', async () => new Response('home')) + + const request = new Request('http://localhost/') + const response = await router.handle(request) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('home') + }) + + test('matches dynamic :param routes', async () => { + const router = new Router() + router.get('/users/:id', async (req, params) => { + return Response.json({ id: params.id }) + }) + + const request = new Request('http://localhost/users/123') + const response = await router.handle(request) + + expect(response.status).toBe(200) + const data = await response.json() as { id: string } + expect(data.id).toBe('123') + }) + + test('matches catch-all [...path] routes', async () => { + const router = new Router() + router.get('/api/[...path]', async (req, params) => { + return Response.json({ path: params.path }) + }) + + const request = new Request('http://localhost/api/users/123/posts') + const response = await router.handle(request) + + expect(response.status).toBe(200) + const data = await response.json() as { path: string } + expect(data.path).toBe('users/123/posts') + }) + + test('returns 404 for unmatched routes', async () => { + const router = new Router() + router.get('/', async () => new Response('home')) + + const request = new Request('http://localhost/unknown') + const response = await router.handle(request) + + expect(response.status).toBe(404) + }) + + test('matches correct HTTP method', async () => { + const router = new Router() + router.get('/resource', async () => new Response('GET')) + router.post('/resource', async () => new Response('POST')) + + const getReq = new Request('http://localhost/resource') + const getRes = await router.handle(getReq) + expect(await getRes.text()).toBe('GET') + + const postReq = new Request('http://localhost/resource', { + method: 'POST' + }) + const postRes = await router.handle(postReq) + expect(await postRes.text()).toBe('POST') + }) + }) + + describe('Route handlers', () => { + test('GET / returns welcome message', async () => { + const { GET } = await import('../src/routes/index') + + const request = new Request('http://localhost/') + const response = await GET(request, {}) + + expect(response.status).toBe(200) + const data = await response.json() as { message: string } + expect(data.message).toBe('Welcome to the home page') + }) + + test('GET /users/:id returns user', async () => { + const { GET } = await import('../src/routes/users/[id]') + + const request = new Request('http://localhost/users/42') + const response = await GET(request, { id: '42' }) + + expect(response.status).toBe(200) + const data = await response.json() as { user: { id: string } } + expect(data.user.id).toBe('42') + }) + + test('GET /api/[...path] returns path segments', async () => { + const { GET } = await import('../src/routes/api/[...path]') + + const request = new Request('http://localhost/api/a/b/c') + const response = await GET(request, { path: 'a/b/c' }) + + expect(response.status).toBe(200) + const data = await response.json() as { segments: string[] } + expect(data.segments).toEqual(['a', 'b', 'c']) + }) + }) +}) diff --git a/cases/case8/tsconfig.json b/cases/case8/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case8/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/case9-shared/package.json b/cases/case9-shared/package.json new file mode 100644 index 0000000..275fc3e --- /dev/null +++ b/cases/case9-shared/package.json @@ -0,0 +1,10 @@ +{ + "name": "@devflare/case9-shared", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + } +} \ No newline at end of file diff --git a/cases/case9-shared/src/index.ts b/cases/case9-shared/src/index.ts new file mode 100644 index 0000000..d5d7797 --- /dev/null +++ b/cases/case9-shared/src/index.ts @@ -0,0 +1,83 @@ +// ============================================================================= +// Case 9: Monorepo - Shared Package +// ============================================================================= +// Shared utilities used across multiple workers in the monorepo +// ============================================================================= + +/** + * Shared utility to format a response + */ +export function formatResponse(data: T) { + return { + success: true as const, + data, + timestamp: Date.now() + } +} + +/** + * Shared utility to format an error + */ +export function formatError(message: string, code: string, status = 500) { + return { + success: false as const, + error: { + message, + code, + status + }, + timestamp: Date.now() + } +} + +/** + * Shared constants + */ +export const CONSTANTS = { + MAX_PAGE_SIZE: 100, + DEFAULT_PAGE_SIZE: 20, + VERSION: '1.0.0' +} as const + +/** + * Shared type definitions + */ +export interface PaginatedRequest { + page?: number + pageSize?: number +} + +export interface PaginatedResponse { + items: T[] + pagination: { + page: number + pageSize: number + totalItems: number + totalPages: number + hasNext: boolean + hasPrev: boolean + } +} + +/** + * Create paginated response + */ +export function paginate( + items: T[], + totalItems: number, + page: number, + pageSize: number +): PaginatedResponse { + const totalPages = Math.ceil(totalItems / pageSize) + return { + items, + pagination: { + page, + pageSize, + totalItems, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1 + } + } +} diff --git a/cases/case9/devflare.config.ts b/cases/case9/devflare.config.ts new file mode 100644 index 0000000..bbf07c0 --- /dev/null +++ b/cases/case9/devflare.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case9-monorepo', + compatibilityDate: '2026-04-26' +}) diff --git a/cases/case9/env.d.ts b/cases/case9/env.d.ts new file mode 100644 index 0000000..5c9c90d --- /dev/null +++ b/cases/case9/env.d.ts @@ -0,0 +1,13 @@ +// Generated by devflare - DO NOT EDIT +// Run `devflare types` to regenerate + +declare global { + interface DevflareEnv { + } +} + +/** + * Named entrypoints (none discovered - add ep.*.ts files to enable). + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = string diff --git a/cases/case9/package.json b/cases/case9/package.json new file mode 100644 index 0000000..a0e43e7 --- /dev/null +++ b/cases/case9/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devflare/case9-monorepo", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "types": "devflare types" + }, + "dependencies": { + "@devflare/case9-shared": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "devflare": "workspace:*", + "typescript": "^5.7.2" + } +} diff --git a/cases/case9/src/fetch.ts b/cases/case9/src/fetch.ts new file mode 100644 index 0000000..132593f --- /dev/null +++ b/cases/case9/src/fetch.ts @@ -0,0 +1,69 @@ +// ============================================================================= +// Case 9: Monorepo - Fetch Handler +// ============================================================================= +// Demonstrates using shared packages from a monorepo +// Using devflare's file-based patterns +// ============================================================================= + +import { + formatResponse, + formatError, + paginate, + CONSTANTS, + type PaginatedRequest +} from '@devflare/case9-shared' + +/** + * Main fetch handler + * Uses shared utilities from monorepo package + */ +export default async function fetch( + request: Request, + env: unknown, + ctx: ExecutionContext +): Promise { + const url = new URL(request.url) + + // Route: GET / + if (url.pathname === '/') { + return Response.json( + formatResponse({ + name: 'Case 9: Monorepo Worker', + version: CONSTANTS.VERSION + }) + ) + } + + // Route: GET /items + if (url.pathname === '/items') { + const page = parseInt(url.searchParams.get('page') || '1') + const pageSize = Math.min( + parseInt(url.searchParams.get('pageSize') || String(CONSTANTS.DEFAULT_PAGE_SIZE)), + CONSTANTS.MAX_PAGE_SIZE + ) + + // Mock data + const allItems = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + name: `Item ${i + 1}` + })) + + const start = (page - 1) * pageSize + const items = allItems.slice(start, start + pageSize) + + return Response.json( + formatResponse(paginate(items, allItems.length, page, pageSize)) + ) + } + + // Route: GET /error + if (url.pathname === '/error') { + return Response.json(formatError('Something went wrong', 'ERR_DEMO', 500), { + status: 500 + }) + } + + return Response.json(formatError('Not found', 'NOT_FOUND', 404), { + status: 404 + }) +} diff --git a/cases/case9/tests/monorepo.test.ts b/cases/case9/tests/monorepo.test.ts new file mode 100644 index 0000000..a704908 --- /dev/null +++ b/cases/case9/tests/monorepo.test.ts @@ -0,0 +1,108 @@ +// ============================================================================= +// Case 9: Monorepo - Tests +// ============================================================================= +// Tests for shared package utilities +// ============================================================================= + +import { describe, expect, it } from 'bun:test' +import { + formatResponse, + formatError, + paginate, + CONSTANTS +} from '@devflare/case9-shared' + +describe('Case 9: Monorepo', () => { + describe('formatResponse', () => { + it('wraps data in success envelope', () => { + const result = formatResponse({ foo: 'bar' }) + + expect(result.success).toBe(true) + expect(result.data).toEqual({ foo: 'bar' }) + expect(result.timestamp).toBeTypeOf('number') + }) + + it('handles arrays', () => { + const result = formatResponse([1, 2, 3]) + + expect(result.success).toBe(true) + expect(result.data).toEqual([1, 2, 3]) + }) + + it('handles null', () => { + const result = formatResponse(null) + + expect(result.success).toBe(true) + expect(result.data).toBeNull() + }) + }) + + describe('formatError', () => { + it('wraps error in envelope', () => { + const result = formatError('Bad request', 'INVALID_INPUT', 400) + + expect(result.success).toBe(false) + expect(result.error).toEqual({ + message: 'Bad request', + code: 'INVALID_INPUT', + status: 400 + }) + expect(result.timestamp).toBeTypeOf('number') + }) + + it('uses default status 500', () => { + const result = formatError('Server error', 'INTERNAL') + + expect(result.error.status).toBe(500) + }) + }) + + describe('paginate', () => { + it('calculates pagination metadata', () => { + const items = [{ id: 1 }, { id: 2 }] + const result = paginate(items, 100, 2, 10) + + expect(result.items).toEqual(items) + expect(result.pagination).toEqual({ + page: 2, + pageSize: 10, + totalItems: 100, + totalPages: 10, + hasNext: true, + hasPrev: true + }) + }) + + it('handles first page', () => { + const result = paginate([], 50, 1, 10) + + expect(result.pagination.page).toBe(1) + expect(result.pagination.hasPrev).toBe(false) + expect(result.pagination.hasNext).toBe(true) + }) + + it('handles last page', () => { + const result = paginate([], 50, 5, 10) + + expect(result.pagination.page).toBe(5) + expect(result.pagination.hasPrev).toBe(true) + expect(result.pagination.hasNext).toBe(false) + }) + + it('handles single page', () => { + const result = paginate([], 5, 1, 10) + + expect(result.pagination.totalPages).toBe(1) + expect(result.pagination.hasPrev).toBe(false) + expect(result.pagination.hasNext).toBe(false) + }) + }) + + describe('CONSTANTS', () => { + it('has expected values', () => { + expect(CONSTANTS.VERSION).toBe('1.0.0') + expect(CONSTANTS.DEFAULT_PAGE_SIZE).toBe(20) + expect(CONSTANTS.MAX_PAGE_SIZE).toBe(100) + }) + }) +}) diff --git a/cases/case9/tsconfig.json b/cases/case9/tsconfig.json new file mode 100644 index 0000000..e11a4b2 --- /dev/null +++ b/cases/case9/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "env.d.ts", + "src/**/*", + "tests/**/*" + ] +} diff --git a/cases/tsconfig.base.json b/cases/tsconfig.base.json new file mode 100644 index 0000000..2ad730e --- /dev/null +++ b/cases/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types", "@types/bun"] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..966e716 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "devflare-monorepo", + "private": true, + "type": "module", + "packageManager": "bun@1.3.12", + "workspaces": [ + "apps/*", + "apps/testing/workers/*", + "packages/*", + "cases/*", + "cases/case9-shared", + "cases/case11-do-shared" + ], + "scripts": { + "devflare": "bunx --bun devflare", + "turbo": "turbo run", + "devflare:dev": "turbo run dev --filter=devflare", + "devflare:test:watch": "turbo run test:watch --filter=devflare", + "devflare:build": "turbo run build --filter=devflare --filter=documentation", + "devflare:typecheck": "turbo run typecheck --filter=devflare", + "devflare:test": "turbo run test --concurrency=1 --filter=...devflare --filter=!@devflare/case5-multi-worker", + "devflare:types": "turbo run types --filter=...devflare", + "devflare:check": "turbo run check --filter=documentation", + "devflare:docs-integrity": "bun test packages/devflare/tests/unit/docs", + "devflare:ci": "bun run devflare:build && bun run devflare:typecheck && bun run devflare:test && bun run devflare:types && bun run devflare:check && bun run devflare:docs-integrity", + "lint:root": "biome check .", + "lint": "turbo run lint:root", + "test:watch": "bun run devflare:test:watch", + "typecheck:root": "tsgo --noEmit", + "typecheck": "turbo run typecheck:root types check", + "types": "bun run typecheck", + "check": "turbo run check", + "test": "turbo run test", + "lint:fix": "biome check --write .", + "build": "turbo run build", + "ci": "bun run devflare:ci", + "ci:strict": "bun run lint:root && bun run typecheck:root && bun run devflare:ci", + "dev": "bun run devflare:dev" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@cloudflare/workers-types": "^4.20260426.1", + "@types/bun": "^1.3.13", + "@typescript/native-preview": "^7.0.0-dev.20260426.1", + "devflare": "workspace:*", + "turbo": "^2.9.6", + "typescript": "^5.9.3" + }, + "overrides": { + "unicorn-magic": "^0.4.0" + } +} diff --git a/packages/devflare/.docs/BRIDGE_ARCHITECTURE.md b/packages/devflare/.docs/BRIDGE_ARCHITECTURE.md new file mode 100644 index 0000000..c837060 --- /dev/null +++ b/packages/devflare/.docs/BRIDGE_ARCHITECTURE.md @@ -0,0 +1,285 @@ +# Devflare Bridge Architecture + +> **RPC naming convention.** Every wire op is namespaced by its binding kind: +> `kv.*`, `r2.*`, `d1.*` (with `d1.stmt.*` for prepared-statement subops), `do.*`, +> `queue.*`, `email.*`, `ai.*`, `var.*`. The full method on the wire is +> `..`, e.g. `MY_KV.kv.get`, `MY_DO.do.idFromName`, +> `MY_DO.do.fetch`, `MY_DO.do.rpc`. The DO-vs-KV overlap on the bare `get` verb +> (resolved as B4) no longer exists at the protocol level โ€” `do.get` and +> `kv.get` are distinct ops. For one release, the in-worker dispatcher still +> accepts legacy bare verbs (and the older `stmt.*` / `stub.*` sub-prefixes), +> auto-translates them based on binding shape, and emits a one-shot +> `console.warn` per deprecated verb. The bridge proxy (`src/bridge/proxy.ts`) +> always emits the namespaced form; only out-of-tree callers exercise the +> legacy path. + +> **Source layout (current).** The bridge lives under `src/bridge/`: +> - `bridge/v2/` โ€” wire-protocol layer: `wire.ts` (RPC envelope, control plane, binary frame header, ID counters, `HTTP_TRANSFER_THRESHOLD`), `frames.ts` (binary frame encode/decode), `codec.ts` (handshake + body-stream registry on top of `wire.ts`), `transport.ts` (per-payload inline-vs-HTTP transport selection), `body-streams.ts` (pull-based stream registry), `value-codec.ts` / `value-serialization.ts` (POJO + StreamRef serialization), `control-messages.ts`, `ws-relay.ts` (WS pass-through plumbing), `serialization.ts`, `index.ts`. +> - `bridge/client.ts` โ€” Node/Bun-side client (used by the public `env` proxy). +> - `bridge/server.ts` โ€” in-worker dispatcher. +> - `bridge/proxy.ts` โ€” the `env` Proxy implementation that translates property access into RPC calls. +> - `bridge/gateway-runtime.ts` โ€” worker-side gateway that runs inside Miniflare. +> - `bridge/miniflare.ts` โ€” Miniflare lifecycle integration. +> +> The legacy `protocol.ts` / `serialization.ts` split referenced by older docs no longer exists โ€” wire-level concerns now live in `bridge/v2/wire.ts` (control vocabulary) and `bridge/v2/value-codec.ts` (+ `value-serialization.ts`) for value shaping. + +## Core Principle + +**Vite/SvelteKit/Node.js runs OUTSIDE workerd. Miniflare runs INSIDE workerd. The bridge connects them.** + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Your Application (Node.js) โ”‚ โ”‚ Miniflare (workerd process) โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ import { env } from 'devflare' โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ DurableObjects โ”‚ โ”‚ +โ”‚ env.CHAT_ROOM.get(id).fetch(req) โ”‚ WS โ”‚ โ”‚ KV Namespaces โ”‚ โ”‚ +โ”‚ env.MY_KV.get('key') โ”‚ โ•โ•> โ”‚ โ”‚ R2 Buckets โ”‚ โ”‚ +โ”‚ env.D1_DB.prepare(...).run() โ”‚ <โ•โ• โ”‚ โ”‚ D1 Databases โ”‚ โ”‚ +โ”‚ env.AI.run(...) โ”‚ โ”‚ โ”‚ Queues, AI, Browser... โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†‘ โ†‘ + โ”‚ โ”‚ + YOUR CODE CLOUDFLARE EMULATION + (runs in Node/Bun/Deno) (runs in workerd) +``` + +## What This Is NOT + +โŒ Running SvelteKit SSR inside workerd +โŒ Merging two workerd runtimes +โŒ Using `getPlatformProxy` which spawns isolated workerd + +## What This IS + +โœ… Single Miniflare process with ALL Cloudflare bindings +โœ… WebSocket-based RPC bridge from Node.js to Miniflare +โœ… `env` Proxy that transparently communicates with Miniflare +โœ… Works in ANY JavaScript runtime (Node, Bun, Deno, browser tests) + +--- + +## Cloudflare Constraints + +| Constraint | Value | Implication | +|------------|-------|-------------| +| WS message size limit | 1 MiB | Must chunk large data | +| Practical chunk size | 64-256 KiB | Balance throughput vs memory | + +--- + +## Transport Strategy: Hybrid (WS + HTTP) + +| Data Size | Transport | Rationale | +|-----------|-----------|-----------| +| < 10 MB | WebSocket | Low latency, unified channel | +| โ‰ฅ 10 MB | HTTP streaming | Better throughput, no chunk overhead | +| DO WebSocket | WebSocket proxy | Must use WS for real-time | +| AI responses | WebSocket stream | Progressive delivery | + +**Large file flow (HTTP fallback)**: +``` +1. RPC: { t: 'rpc.call', method: 'MY_BUCKET.put', params: ['big.zip', { httpUpload: true }] } +2. Response: { t: 'rpc.ok', result: { uploadUrl: 'http://localhost:PORT/upload/xyz' } } +3. Client streams file to uploadUrl via HTTP PUT +4. Gateway streams to R2 binding +``` + +--- + +## Protocol Design + +The bridge multiplexes 4 "planes" over ONE WebSocket: + +1. **RPC calls**: Node โ†’ Worker ("invoke binding method"), Worker โ†’ Node (result/error) +2. **Events**: Worker โ†’ Node (DO broadcasts, queue messages, logs) +3. **Byte streams**: Pull-based streaming with credit flow control +4. **WS proxy**: Browser WS โ†” DO WS, proxied through the bridge + +### Control Plane (JSON text frames) + +```typescript +type JsonMsg = + // RPC + | { t: 'rpc.call'; id: string; method: string; params: unknown[] } + | { t: 'rpc.ok'; id: string; result: unknown } + | { t: 'rpc.err'; id: string; error: { code: string; message: string } } + + // Events + | { t: 'event'; topic: string; data: unknown } + + // Stream control (pull-based backpressure) + | { t: 'stream.open'; sid: number; meta?: { contentType?: string; length?: number } } + | { t: 'stream.pull'; sid: number; creditBytes: number } + | { t: 'stream.end'; sid: number } + | { t: 'stream.abort'; sid: number; error?: string } + + // WebSocket proxy control + | { t: 'ws.open'; wid: number; target: { binding: string; id: string; url: string } } + | { t: 'ws.opened'; wid: number } + | { t: 'ws.close'; wid: number; code?: number; reason?: string } +``` + +### Data Plane (Binary frames) + +Binary frames carry stream chunks or proxied WS payloads: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ kind โ”‚ id โ”‚ seq โ”‚ flags โ”‚ payload... โ”‚ +โ”‚ u8 โ”‚ u32 โ”‚ u32 โ”‚ u8 โ”‚ bytes โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +kind: + 1 = stream chunk (id = sid) + 2 = ws data (id = wid) + +flags: + bit0 = FIN (last chunk/frame) + bit1 = TEXT vs BINARY (for ws data) +``` + +--- + +## Serialization Strategy + +### Principle: Never stringify real Request/Response + +Convert to POJOs + StreamRef for bodies. + +```typescript +// SerializedRequest +interface SerializedRequest { + url: string + method: string + headers: [string, string][] + body?: { sid: number } | { bytes: Uint8Array } | null +} + +// SerializedResponse +interface SerializedResponse { + status: number + statusText?: string + headers: [string, string][] + body?: { sid: number } | { bytes: Uint8Array } | null + webSocket?: { wid: number } // For WS upgrades +} +``` + +### Streams as References + +Large bodies become `{ sid: number }` references: +- Binary frames deliver the bytes +- Consumer drives flow with `stream.pull` credits + +--- + +## Pull-Based Streaming (Backpressure) + +**Why pull-based?** Prevents memory blowup when streaming 3GB files. + +``` +Receiver: stream.pull { sid: 1, creditBytes: 262144 } // Request 256KB +Sender: [binary frame: sid=1, 64KB chunk] +Sender: [binary frame: sid=1, 64KB chunk] +Sender: [binary frame: sid=1, 64KB chunk] +Sender: [binary frame: sid=1, 64KB chunk] +Receiver: stream.pull { sid: 1, creditBytes: 262144 } // Request more +... +Sender: stream.end { sid: 1 } // Done +``` + +--- + +## DO WebSocket Pass-through + +Browser connects to SvelteKit (not directly to Miniflare): + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” WS โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Bridge โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” DO WS โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ SvelteKit โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ Miniflare โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ DO โ”‚ +โ”‚ โ”‚ โ”‚ (Node.js) โ”‚ โ”‚ (workerd) โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†‘ โ†‘ โ†‘ โ†‘ + /chat/123 Accept upgrade, ws.open { wid, Accept WS, + allocate wid, binding, id } relay frames + relay frames +``` + +**Why this way?** +- Same-origin (no CORS issues) +- Cookies/auth work normally +- Dev/prod parity +- SvelteKit routing works + +--- + +## The internal `bridgeEnv` proxy + +```typescript +// Internal proxy that: +// 1. Lazily connects to Miniflare on first access +// 2. Translates method calls to RPC messages +// 3. Returns Promises that resolve when Miniflare responds + +await bridgeEnv.MY_KV.get('key') +// โ†’ RPC: { t: 'rpc.call', id: '1', method: 'MY_KV.kv.get', params: ['key'] } +// โ† Response: { t: 'rpc.ok', id: '1', result: 'stored-value' } + +const stub = bridgeEnv.CHAT_ROOM.get(id) +await stub.fetch(request) +// โ†’ RPC: { t: 'rpc.call', id: '2', method: 'CHAT_ROOM.do.idFromName', params: [id] } +// โ†’ RPC: { t: 'rpc.call', id: '3', method: 'CHAT_ROOM.do.fetch', params: [stubRef, serializedReq] } +``` + +> **Note**: `bridgeEnv` is an internal bridge-layer primitive, not part of the stable root package contract. +> Public application code should usually use `import { env } from 'devflare'` inside request/test flows, or lower-level bridge helpers such as `createEnvProxy()` / `initEnv()` for advanced bridge work. + +--- + +## Initialization + +```typescript +// Lazy init (primary internal pattern) +await bridgeEnv.MY_KV.get('key') // Auto-connects + +// Explicit init +await getClient().connect() +await bridgeEnv.MY_KV.get('key') +``` + +--- + +## CLI + +```bash +bunx --bun devflare dev # Unified local dev server +bunx --bun devflare remote status # Show remote test mode +bunx --bun devflare remote enable 30 # Enable remote-only tests for 30 minutes +``` + +--- + +## File Structure + +``` +packages/devflare/src/bridge/ +โ”œโ”€โ”€ v2/ +โ”‚ โ”œโ”€โ”€ wire.ts # RPC envelope, control vocab, binary frame header, ID counters +โ”‚ โ”œโ”€โ”€ frames.ts # Binary frame encode/decode +โ”‚ โ”œโ”€โ”€ codec.ts # Hello/welcome handshake + body-stream registry on top of wire.ts +โ”‚ โ”œโ”€โ”€ transport.ts # Per-payload inline-vs-HTTP transport selection (HTTP_TRANSFER_THRESHOLD) +โ”‚ โ”œโ”€โ”€ body-streams.ts # Pull-based stream registry +โ”‚ โ”œโ”€โ”€ value-codec.ts # Request/Response/StreamRef value shaping +โ”‚ โ”œโ”€โ”€ value-serialization.ts +โ”‚ โ”œโ”€โ”€ control-messages.ts +โ”‚ โ”œโ”€โ”€ ws-relay.ts # WS pass-through plumbing +โ”‚ โ”œโ”€โ”€ serialization.ts +โ”‚ โ””โ”€โ”€ index.ts +โ”œโ”€โ”€ client.ts # Node/Bun WebSocket client +โ”œโ”€โ”€ server.ts # Gateway dispatcher (in-worker) +โ”œโ”€โ”€ proxy.ts # `env` Proxy +โ”œโ”€โ”€ gateway-runtime.ts # Worker-side gateway running inside Miniflare +โ””โ”€โ”€ miniflare.ts # Miniflare lifecycle integration +``` diff --git a/packages/devflare/.gitignore b/packages/devflare/.gitignore new file mode 100644 index 0000000..32c4e63 --- /dev/null +++ b/packages/devflare/.gitignore @@ -0,0 +1 @@ +_tr_*.txt diff --git a/packages/devflare/LLM.md b/packages/devflare/LLM.md new file mode 100644 index 0000000..7c13165 --- /dev/null +++ b/packages/devflare/LLM.md @@ -0,0 +1,15957 @@ +# Devflare documentation markdown export + +This file is generated from the structured documentation model in `apps/documentation/src/lib/docs/content*.ts` during the documentation build and deploy pipeline. + +Do not edit this file by hand; update the docs model and regenerate the export instead. + +It is meant to read like a proper markdown handbook rather than a second source of truth, so the docs site and the `LLM.md` export stay aligned. + +## How to use this export + +- Read the documentation map first to find the relevant page and route quickly. +- Each page includes a short summary, metadata, key takeaways, and the fully expanded sections from the docs source. +- Links use the same `/docs/...` routes as the documentation site. + +## Documentation map +This export covers 147 pages across 5 top-level groups. + +### Quickstart +See why Devflare exists, build the smallest safe first worker, and move into routes, bindings, previews, and tests when the app needs them. + +- **Foundations** โ€” Start with the mental model, the smallest safe worker, and one real test before you branch into app-specific setup. + - [Why Devflare](/docs/what-devflare-is) โ€” Devflare gives you one clearer story for config, worker compilation, local development, runtime helpers, testing, and deploy flows so a Worker app can stay small at the start and still stay coherent as it grows. + - [Your first worker](/docs/first-worker) โ€” Start with one config file, one fetch handler, and generated types before you branch into routes, bindings, frameworks, or a deeper test setup. + - [Your first unit test](/docs/first-unit-test) โ€” Take the same starter worker from the previous page and add one request test through `createTestContext()` so the first check uses the same runtime shape the worker will actually run. + - [Your first route tree](/docs/first-route-tree) โ€” The first route-tree step should only change project shape: config, request-wide middleware, one route, and one worker-level test. + - [Your first bindings](/docs/first-bindings) โ€” Take the same starter worker, split it into routes and helpers, then add one binding-backed route at a time so `src/fetch.ts` can stay small. + - [Deploy and Preview](/docs/deploy-and-preview) โ€” Take the same starter worker, ship one named preview, then remove that preview scope cleanly. + +### Devflare +Keep the day-to-day Devflare surfaces easy to scan: runtime model, HTTP split, authored config rules, CLI workflow, helpers, testing, and framework lanes all live here instead of being scattered across deploy-only docs. +- [CLI](/docs/devflare-cli) โ€” Start at `devflare --help`: the root page already maps local dev, inspection, deploy intent, account inventory, preview lifecycle, production control, token management, AI pricing, and remote-mode operations in one place. +- [Project Architecture](/docs/project-architecture) โ€” This is the practical answer to โ€œwhat does a real Devflare project look like on disk?โ€ โ€” from a small worker package, to a multi-surface app, to a hosted SvelteKit package, to a Bun monorepo with several deployable workers. +- [Bridge internals](/docs/bridge-architecture-internals) โ€” The bridge architecture document remains valuable, but it should not be on the first-hour developer path. +- [Routing](/docs/http-routing) โ€” Use `src/fetch.ts` for request-wide behavior, `src/routes/**` for leaf handlers, and `files.routes` when you need a custom root, prefix, or route-only app. + +- **Configuration** โ€” Keep authored config readable, stable, and clearly separated from generated output. + - [Config basics](/docs/config-basics) โ€” Write `devflare.config.ts` for humans first, let Devflare merge environments and resolve names later, and treat generated Wrangler-facing files as outputs rather than authoring surfaces. + - [Full config](/docs/full-config) โ€” See one canonical `devflare.config.ts` that touches the main current config lanes in a single file, with hover coverage on every property shown in the example. + - [Project shape](/docs/project-shape) โ€” Start with one fetch file, then add routes, background handlers, Durable Objects, assets, and transport rules only when the project genuinely needs them. + - [Worker surfaces](/docs/worker-surfaces) โ€” Devflare can compose or wrap several Worker surfaces into one generated entrypoint, but the authored source of truth should stay in explicit files such as `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, and `src/email.ts`. + - [Generated types](/docs/generated-types) โ€” `devflare types` turns config, discovered Durable Objects, named entrypoints, and cross-worker references into one generated TypeScript contract instead of a pile of hand-maintained env guesswork. + - [Environments](/docs/config-environments) โ€” Keep one base config, layer environment-specific overrides with `config.env`, and let Devflare resolve preview or production details only in the commands that actually need them. + - [Typed env vars](/docs/typed-env-vars) โ€” Use `env.NAME` descriptors inside `defineConfig({ vars })`, parse or default them in config, and read the resulting typed values at runtime with `import { vars } from "devflare"`. + - [Previews](/docs/config-previews) โ€” Use `preview.scope()` for bindings that should belong to one preview scope. Devflare materializes names like `notes-db-next`, provisions or reuses the preview-only resources it can manage, and lets you clean them up by the same scope later without touching production resources. + - [Runtime & deploy settings](/docs/runtime-deploy-settings) โ€” Use config for account context, compatibility posture, assets, deployment routes, WebSocket proxy rules, migrations, observability, limits, and preview cron behavior instead of rediscovering those settings in scripts later. + +- **Runtime** โ€” Use runtime helpers, request-wide middleware, transport hooks, and other worker-wide surfaces without turning every page into an internals guide. + - [Runtime context](/docs/runtime-context) โ€” Use explicit event parameters at handler boundaries, then use `getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, and `locals` inside helper code that runs during the same request or job. + - [Runtime internals](/docs/runtime-context-internals) โ€” This page keeps the AsyncLocalStorage mechanics out of the normal usage guide while preserving them for maintainers and advanced debugging. + - [sequence(...)](/docs/sequence-middleware) โ€” Use `sequence(...)` from `devflare/runtime` when broad HTTP concerns must wrap route resolution or another fetch handler in a clear top-to-bottom order. + - [Handler styles](/docs/runtime-handler-styles) โ€” Devflare runtime supports event-first handlers, request-wide `sequence()` middleware, route method handlers, and explicit markers for ambiguous two-argument worker-style or resolve-style functions. + - [transport.ts](/docs/transport-file) โ€” Most workers do not need a transport file. Add one when Devflareโ€™s local RPC-style bridge must encode and decode custom values, especially across Durable Object method calls in tests. + +- **Testing** โ€” Start with why the testing experience feels different, use the testing map and built-in harness for runtime-shaped checks, and jump to binding-specific guides when the test story changes by binding. + - [Why tests feel native](/docs/why-testing-feels-native) โ€” Devflareโ€™s standout testing trick is that the same config, bindings, env surface, runtime helpers, and even direct Durable Object method calls can stay available in Bun tests without a hand-built fake layer in the middle. + - [Testing overview](/docs/testing-overview) โ€” Devflareโ€™s testing story is layered: start with one real unit test, use `createTestContext()` and `cf.*` for the runtime-shaped harness, then jump to binding-specific guides or CI-focused pages only when the question changes. + - [createTestContext()](/docs/create-test-context) โ€” Start tests with `createTestContext()` so the same config, bindings, routes, and handler surfaces the app uses in real runtime flows are available in Bun tests. + - [Binding testing](/docs/binding-testing-guides) โ€” Every binding overview page already links a hidden testing guide. This page collects those guides in one place so you can jump straight to the right harness, caveats, and escalation path for the binding that changed. + - [Test helper reference](/docs/test-helper-reference) โ€” Use this reference when you know you need the test package but not which helper surface is the smallest truthful proof. + +- **Frameworks** โ€” Choose the right host lane for worker-rendered Svelte, standalone Vite apps, and full SvelteKit shells without losing the worker-first mental model. + - [Svelte in workers](/docs/svelte-with-rolldown) โ€” When a worker-only fetch surface or Durable Object imports `.svelte`, add the Svelte compiler to `rolldown.options.plugins`. That compilation belongs to Devflareโ€™s worker bundler, not the main Vite plugin chain. + - [Vite standalone](/docs/vite-standalone) โ€” An effective Vite config is what opts the package into Vite-backed flows: a local `vite.config.*`, a non-empty `config.vite`, or both together. Use `devflare/vite` when the package really is a Vite app and you want Devflare to keep Worker config, Durable Objects, and generated Wrangler output aligned underneath it. + - [SvelteKit](/docs/sveltekit-with-devflare) โ€” Hand SvelteKit's Cloudflare adapter output to Devflare via `wrangler.passthrough.main` (the adapter worker is a build artifact and does not exist until `vite build` runs), keep `sveltekit()` in `vite.config.ts`, and compose `devflare/sveltekit` into `src/hooks.server.ts` so local platform bindings line up with the Worker runtime Devflare manages. + +### Ship & operate +Deploy explicitly, choose the right preview model, manage preview lifecycle cleanly, and keep CI/CD plus verification honest. + +- **CI/CD** โ€” Use small GitHub workflows that keep triggers, permissions, impact checks, deploy intent, and feedback easy to review. + - [GitHub workflows](/docs/github-workflows) โ€” Devflare ships reusable GitHub Actions for setup, impact checks, deploy execution, and feedback, plus supported workflow strategies for validation, previews, production, and cleanup. + +- **Deploy targets** โ€” Move from local build output to production or preview deploys without guessing which destination you are about to hit. + - [Deploy recipes](/docs/deploy-command-recipes) โ€” Use build, dry-run, production deploy, named preview deploy, same-worker preview upload, cleanup, and GitHub Actions as separate recipes with visible effects. + - [Production deploys](/docs/production-deploys) โ€” Production uses `--prod` or `--production`, preview uses `--preview` or `--preview `. No target means no deploy. + - [Monorepos & Turborepo](/docs/monorepo-turborepo) โ€” Turbo owns task orchestration and caching. `devflare` still runs from the package that owns the Worker or app. + - [Preview strategies](/docs/preview-strategies) โ€” Same-worker uploads, named preview scopes, and branch-scoped worker families serve different needs. + +- **Operations** โ€” Choose account context, inspect live production, manage Worker names and tokens, gate paid remote tests deliberately, and reuse the public Cloudflare helper API when automation needs the same rules. + - [Control-plane operations](/docs/control-plane-operations) โ€” Devflareโ€™s deeper CLI families exist so account selection, live production inspection, Worker renames, token lifecycle, and remote paid-test gates stay documented instead of dissolving into ad-hoc command snippets. + - [devflare/cloudflare](/docs/cloudflare-api) โ€” The `devflare/cloudflare` subpath exposes the same account-aware building blocks the CLI uses for auth, resource inventory, usage and limits, preview registry access, preferences, and managed token workflows. + +- **Preview lifecycle** โ€” Inspect and clean up preview scopes after they exist so preview infrastructure does not sprawl. + - [Preview operations](/docs/preview-operations) โ€” The preview registry is D1-backed, giving Devflare durable records of scope and deployment state for reliable cleanup. + +- **Verification** โ€” Use runtime-shaped tests and keep automation observable enough to trust during releases. + - [Testing & automation](/docs/testing-and-automation) โ€” Local harness detail stays on the testing pages. This page covers what gets promoted into CI and how automation stays observable. + - [Docs release gates](/docs/docs-release-gates) โ€” Public exports, schema keys, compiler output, typegen, CLI commands, test helpers, and support stances should fail CI when the docs do not change with them. + +### Guides +Use cross-cutting guides to choose the right storage, state, async, file-delivery, and worker-composition patterns before you dive into one binding reference page. + +- **Guides** โ€” Choose the right architecture and product boundary first, then let the specific binding pages own the exact authoring and runtime mechanics. + - [Feature index](/docs/feature-index) โ€” This page is the compact feature support index that keeps support level, Cloudflare boundary, test helper, preview lifecycle, and docs links in one place. + - [Storage strategy](/docs/storage-bindings) โ€” Use this page to choose between KV, D1, R2, and Hyperdrive. Once the shape is clear, open the binding-specific guide for authoring, testing, and examples instead of reading several smaller pages that all repeat the same decision badly. + - [R2 uploads & delivery](/docs/r2-uploads-and-delivery) โ€” Use presigned `PUT` URLs for direct uploads, public buckets on custom domains for truly public assets, and private buckets plus Worker auth for protected files. Keep `r2.dev` out of production, and when a preview or environment needs its own bucket, scope it intentionally instead of borrowing production storage. + - [State & async patterns](/docs/durable-objects-and-queues) โ€” Use Durable Objects when one identity should own state or coordination. Use queues when work should happen later, in batches, or with retries. Then open the specific binding guide once the pattern is clear. + - [Worker composition](/docs/multi-workers) โ€” Use this page for the architecture question: when a separate worker boundary is justified, how `ref()` and service bindings keep it explicit, and where local tests and release checks should prove the wiring. + +### Bindings +Use the per-binding guides for the exact authoring, runtime, testing, preview, and example details once the guide pages have already helped you choose the right pattern. + +- **KV** โ€” Fast lookup state, cache-like reads, and lightweight shared data with strong local support. + - [KV](/docs/bindings/kv) โ€” Author stable KV names in config, keep env typed, and run real get or put flows locally. + - [KV internals](/docs/bindings/kv/internals) โ€” KV goes through the full Devflare pipeline: normalize authoring, resolve names when needed, then compile to Wrangler output. + - [Testing KV](/docs/bindings/kv/testing) โ€” Use the default test harness first. KV is one of the bindings Devflare supports best in local tests. + - [KV example](/docs/bindings/kv/example) โ€” This example keeps KV simple: one binding, one fetch handler, one assertion. + +- **D1** โ€” SQLite-style relational queries with a strong local harness and id or name-based authoring. + - [D1](/docs/bindings/d1) โ€” D1 gets the same stable-name authoring story as KV, but the runtime shape is relational: `prepare`, `batch`, `exec`, and prepared statements. + - [D1 internals](/docs/bindings/d1/internals) โ€” D1 uses the same normalize-then-resolve pattern as KV, but compiles to Wrangler `d1_databases` and exposes a relational local runtime surface. + - [Testing D1](/docs/bindings/d1/testing) โ€” D1 is one of the easiest bindings to test meaningfully with Devflare because the local runtime already speaks the same database API your worker uses. + - [D1 example](/docs/bindings/d1/example) โ€” This starter example keeps D1 focused on one job: answer a single query and prove the binding works locally. + +- **R2** โ€” Object storage bindings with strong local support and one important rule: do not assume a browser URL contract. + - [R2](/docs/bindings/r2) โ€” R2 is straightforward in config and well-supported locally, but browser-facing delivery should usually go through a Worker route instead of assuming bucket URLs. + - [R2 internals](/docs/bindings/r2/internals) โ€” R2 is simpler than KV or D1 because the authored value is already the bucket name, so there is no name-versus-id resolution dance. + - [Testing R2](/docs/bindings/r2/testing) โ€” R2 is local-friendly, which means you can test real object operations without inventing a storage adapter just to get off the ground. + - [R2 example](/docs/bindings/r2/example) โ€” This example uses one private bucket and one route, which is still the cleanest default shape for many real apps. + +- **Durable Objects** โ€” Stateful coordination primitives with strong local support, cross-worker wiring, and important preview caveats. + - [Durable Objects](/docs/bindings/durable-objects) โ€” The fast Devflare payoff is simple: put one counter object in a `do.*` file, call it from the worker, and call the same object directly in tests. + - [Durable Objects internals](/docs/bindings/durable-objects/internals) โ€” Durable Object bindings normalize into a stable binding shape, compile into Wrangler `durable_objects.bindings`, and participate in Devflareโ€™s own DO bundling path. + - [Testing Durable Objects](/docs/bindings/durable-objects/testing) โ€” Durable Objects are well-supported in the default Devflare harness, which means you can test real object behavior without hand-building a fake namespace first. + - [Durable Objects example](/docs/bindings/durable-objects/example) โ€” A real Durable Objects application path with config and runtime code kept side by side. + +- **Queues** โ€” Producer and consumer bindings for background work with a strong local trigger story. + - [Queues](/docs/bindings/queues) โ€” Devflare models Queue producers and consumers explicitly, which makes local tests and preview naming much easier to reason about. + - [Queues internals](/docs/bindings/queues/internals) โ€” Queue config is compiled into explicit producer and consumer blocks, with preview resource materialization available for both queue names and DLQs. + - [Testing Queues](/docs/bindings/queues/testing) โ€” Queue testing is one of the places where Devflareโ€™s helper surface feels especially good because the queue trigger already knows how to drive the real handler shape. + - [Queues example](/docs/bindings/queues/example) โ€” This starter example wires one producer, one consumer, and one stored result so you can see the whole queue loop without ceremony. + +- **Services** โ€” Worker-to-worker bindings with `ref()` support, typed env generation, and good local multi-worker tests. + - [Services](/docs/bindings/services) โ€” The fast Devflare payoff is simple: wire one worker to another with `ref()`, call it through `env.MATH_SERVICE`, and prove the same relationship locally in one test. + - [Services internals](/docs/bindings/services/internals) โ€” Devflare resolves referenced worker configs, bundles the linked worker surfaces, and then exposes those services as local multi-worker bindings. + - [Testing Services](/docs/bindings/services/testing) โ€” Service bindings are one of the clearest Devflare wins in multi-worker apps: you can keep the real worker boundary and still prove it through the default local harness. + - [Services example](/docs/bindings/services/example) โ€” A real Services application path with config and runtime code kept side by side. + +- **AI** โ€” Workers AI bindings for remote inference, with a deliberately remote-oriented testing story. + - [AI](/docs/bindings/ai) โ€” Devflare makes Workers AI usable by keeping the binding tiny in config, the worker call obvious, and the remote smoke test explicit instead of fake. + - [AI internals](/docs/bindings/ai/internals) โ€” AI has a smaller compiler story than storage bindings, but a more explicit auth and remote-runtime story. + - [Testing AI](/docs/bindings/ai/testing) โ€” The right AI test strategy is selective: use remote mode when you mean to test inference, and skip cleanly when the environment is not allowed to do that. + - [AI example](/docs/bindings/ai/example) โ€” A real AI application path with config and runtime code kept side by side. + +- **Vectorize** โ€” Vector similarity indexes with explicit remote testing and preview-aware index naming. + - [Vectorize](/docs/bindings/vectorize) โ€” Devflare makes Vectorize usable by keeping the index name explicit in config, preview naming honest, and the real smoke test explicit instead of buried under mocks. + - [Vectorize internals](/docs/bindings/vectorize/internals) โ€” Vectorize compiles cleanly into Wrangler output and participates in preview resource lifecycle, but the runtime value of the binding mostly lives in remote infrastructure. + - [Testing Vectorize](/docs/bindings/vectorize/testing) โ€” The right Vectorize tests are targeted remote checks: a small insert or query, a clear skip condition, and a real index behind the binding. + - [Vectorize example](/docs/bindings/vectorize/example) โ€” A real Vectorize application path with config and runtime code kept side by side. + +- **Hyperdrive** โ€” PostgreSQL-oriented bindings with schema support, name resolution, and local connection strings for Miniflare. + - [Hyperdrive](/docs/bindings/hyperdrive) โ€” Hyperdrive is modeled in Devflare config, compile flows, local Miniflare wiring, and pure tests through explicit local connection strings. + - [Hyperdrive internals](/docs/bindings/hyperdrive/internals) โ€” Hyperdrive uses the same normalize-and-resolve pattern as KV and D1, and local runtime config is driven by the binding connection string. + - [Testing Hyperdrive](/docs/bindings/hyperdrive/testing) โ€” Hyperdrive testing should start with a local connection string, then add a focused query test against the database shape your app actually uses. + - [Hyperdrive example](/docs/bindings/hyperdrive/example) โ€” This example uses Hyperdrive in an application route that reads one product from PostgreSQL. + +- **Browser Rendering** โ€” Headless browser support with an explicit single-binding limit and a stronger dev-server story than test-helper story. + - [Browser Rendering](/docs/bindings/browser-rendering) โ€” Browser Rendering shines in Devflareโ€™s bridge-backed dev story: keep one browser binding, one narrow worker route, and one smoke path that proves launch works. + - [Browser Rendering internals](/docs/bindings/browser-rendering/internals) โ€” Browser Rendering support in Devflare is more than a config pass-through: the dev server starts a browser shim and a binding worker that line up with Cloudflare and puppeteer expectations. + - [Testing Browser Rendering](/docs/bindings/browser-rendering/testing) โ€” Browser tests should usually be integration-flavored: either drive the worker in dev or exercise a thin smoke path that proves the binding can launch and fetch. + - [Browser Rendering example](/docs/bindings/browser-rendering/example) โ€” This example shows the real browser path people actually need: one binding, one title-read route, and one smoke check through the dev server. + +- **Analytics Engine** โ€” Dataset bindings for writeDataPoint-style event recording with schema support and lighter local testing guidance. + - [Analytics Engine](/docs/bindings/analytics-engine) โ€” Analytics Engine is modeled cleanly in Devflare config and generated types, but the repo evidence points to a lighter local story than KV, D1, and R2. + - [Analytics Engine internals](/docs/bindings/analytics-engine/internals) โ€” Analytics Engine has a straightforward compiler story, plus a preview note that matters because datasets are auto-created on first write instead of provisioned like buckets or databases. + - [Testing Analytics Engine](/docs/bindings/analytics-engine/testing) โ€” Analytics Engine tests should stay thin: verify that the worker writes a data point, not that you can recreate Cloudflare analytics locally. + - [Analytics Engine example](/docs/bindings/analytics-engine/example) โ€” This example writes one analytics event from one route, which is usually all you need to teach the binding shape clearly. + +- **Send Email** โ€” Outbound email bindings with real local support, plus an important distinction from inbound email event handlers. + - [Send Email](/docs/bindings/send-email) โ€” Send Email is a real binding surface in Devflare, and it is worth documenting separately from inbound `src/email.ts` handlers so the two flows do not get blurred together. + - [Send Email internals](/docs/bindings/send-email/internals) โ€” Send Email compiles into Wrangler output, normalizes message input at runtime, and supports local address restrictions instead of treating email as an unbounded free-for-all. + - [Testing Send Email](/docs/bindings/send-email/testing) โ€” Send Email is stronger locally than many platform-service bindings because outbound email can be exercised in the default harness, while inbound email has its own related helper surface. + - [Send Email example](/docs/bindings/send-email/example) โ€” This example keeps outbound email explicit: one binding, one recipient rule, one worker path that sends one message. + +- **Rate Limiting** โ€” Fixed-window request limits with Miniflare-backed local behavior and a pure mock for unit tests. + - [Rate Limiting](/docs/bindings/rate-limiting) โ€” Add the Rate Limiting config, call `RateLimit` from worker code, and start with the local test path Devflare supports. + - [Rate Limiting internals](/docs/bindings/rate-limiting/internals) โ€” Rate Limiting compiles from `bindings.rateLimits` to Wrangler `ratelimits`, with local/test behavior called out explicitly. + - [Testing Rate Limiting](/docs/bindings/rate-limiting/testing) โ€” Test Rate Limiting by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Rate Limiting example](/docs/bindings/rate-limiting/example) โ€” A compact Rate Limiting recipe with config and worker usage in one application path. + +- **Version Metadata** โ€” Version identity for deployed Workers, with deterministic metadata in local tests. + - [Version Metadata](/docs/bindings/version-metadata) โ€” Add the Version Metadata config, call `WorkerVersionMetadata` from worker code, and start with the local test path Devflare supports. + - [Version Metadata internals](/docs/bindings/version-metadata/internals) โ€” Version Metadata compiles from `bindings.versionMetadata` to Wrangler `version_metadata`, with local/test behavior called out explicitly. + - [Testing Version Metadata](/docs/bindings/version-metadata/testing) โ€” Test Version Metadata by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Version Metadata example](/docs/bindings/version-metadata/example) โ€” A compact Version Metadata recipe with config and worker usage in one application path. + +- **Worker Loaders** โ€” Dynamic Worker loader bindings for apps that explicitly supply or mock tenant Worker payloads. + - [Worker Loaders](/docs/bindings/worker-loaders) โ€” Add the Worker Loaders config, call `WorkerLoader` from worker code, and start with the local test path Devflare supports. + - [Worker Loaders internals](/docs/bindings/worker-loaders/internals) โ€” Worker Loaders compiles from `bindings.workerLoaders` to Wrangler `worker_loaders`, with local/test behavior called out explicitly. + - [Testing Worker Loaders](/docs/bindings/worker-loaders/testing) โ€” Test Worker Loaders by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Worker Loaders example](/docs/bindings/worker-loaders/example) โ€” A compact Worker Loaders recipe with config and worker usage in one application path. + +- **Secrets Store** โ€” Account-level Secrets Store bindings with local read-only values for dev and tests. + - [Secrets Store](/docs/bindings/secrets-store) โ€” Add the Secrets Store config, call `SecretsStoreSecret` from worker code, and start with the local test path Devflare supports. + - [Secrets Store internals](/docs/bindings/secrets-store/internals) โ€” Secrets Store compiles from `bindings.secretsStore` to Wrangler `secrets_store_secrets`, with local/test behavior called out explicitly. + - [Testing Secrets Store](/docs/bindings/secrets-store/testing) โ€” Test Secrets Store by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Secrets Store example](/docs/bindings/secrets-store/example) โ€” A compact Secrets Store recipe with config and worker usage in one application path. + +- **AI Search** โ€” AI Search instance and namespace bindings with fixture-backed local tests and remote relevance boundaries. + - [AI Search](/docs/bindings/ai-search) โ€” Add the AI Search config, call `AiSearchInstance` or `AiSearchNamespace` from worker code, and start with the local test path Devflare supports. + - [AI Search internals](/docs/bindings/ai-search/internals) โ€” AI Search compiles from `bindings.aiSearch` to Wrangler `ai_search` / `ai_search_namespaces`, with local/test behavior called out explicitly. + - [Testing AI Search](/docs/bindings/ai-search/testing) โ€” Test AI Search by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [AI Search example](/docs/bindings/ai-search/example) โ€” A compact AI Search recipe with config and worker usage in one application path. + +- **mTLS Certificates** โ€” mTLS certificate Fetcher bindings with local handler fixtures and remote certificate-presentation boundaries. + - [mTLS Certificates](/docs/bindings/mtls-certificates) โ€” Add the mTLS Certificates config, call `Fetcher` from worker code, and start with the local test path Devflare supports. + - [mTLS Certificates internals](/docs/bindings/mtls-certificates/internals) โ€” mTLS Certificates compiles from `bindings.mtlsCertificates` to Wrangler `mtls_certificates`, with local/test behavior called out explicitly. + - [Testing mTLS Certificates](/docs/bindings/mtls-certificates/testing) โ€” Test mTLS Certificates by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [mTLS Certificates example](/docs/bindings/mtls-certificates/example) โ€” A compact mTLS Certificates recipe with config and worker usage in one application path. + +- **Dispatch Namespaces** โ€” Workers for Platforms dispatch bindings with explicit local tenant fetcher fixtures. + - [Dispatch Namespaces](/docs/bindings/dispatch-namespaces) โ€” Add the Dispatch Namespaces config, call `DispatchNamespace` from worker code, and start with the local test path Devflare supports. + - [Dispatch Namespaces internals](/docs/bindings/dispatch-namespaces/internals) โ€” Dispatch Namespaces compiles from `bindings.dispatchNamespaces` to Wrangler `dispatch_namespaces`, with local/test behavior called out explicitly. + - [Testing Dispatch Namespaces](/docs/bindings/dispatch-namespaces/testing) โ€” Test Dispatch Namespaces by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Dispatch Namespaces example](/docs/bindings/dispatch-namespaces/example) โ€” A compact Dispatch Namespaces recipe with config and worker usage in one application path. + +- **Workflows** โ€” Workflow bindings for starting and inspecting workflow instances from Workers. + - [Workflows](/docs/bindings/workflows) โ€” Add the Workflows config, call `Workflow` from worker code, and start with the local test path Devflare supports. + - [Workflows internals](/docs/bindings/workflows/internals) โ€” Workflows compiles from `bindings.workflows` to Wrangler `workflows`, with local/test behavior called out explicitly. + - [Testing Workflows](/docs/bindings/workflows/testing) โ€” Test Workflows by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Workflows example](/docs/bindings/workflows/example) โ€” A compact Workflows recipe with config and worker usage in one application path. + +- **Pipelines** โ€” Pipeline bindings for event ingestion, with local send recording and Cloudflare-managed sinks. + - [Pipelines](/docs/bindings/pipelines) โ€” Add the Pipelines config, call `Pipeline` from worker code, and start with the local test path Devflare supports. + - [Pipelines internals](/docs/bindings/pipelines/internals) โ€” Pipelines compiles from `bindings.pipelines` to Wrangler `pipelines`, with local/test behavior called out explicitly. + - [Testing Pipelines](/docs/bindings/pipelines/testing) โ€” Test Pipelines by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Pipelines example](/docs/bindings/pipelines/example) โ€” A compact Pipelines recipe with config and worker usage in one application path. + +- **Images** โ€” Cloudflare Images binding docs with singleton config, local chain-shape tests, and hosted-image boundaries. + - [Images](/docs/bindings/images) โ€” Add the Images config, call `ImagesBinding` from worker code, and start with the local test path Devflare supports. + - [Images internals](/docs/bindings/images/internals) โ€” Images compiles from `bindings.images` to Wrangler `images`, with local/test behavior called out explicitly. + - [Testing Images](/docs/bindings/images/testing) โ€” Test Images by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Images example](/docs/bindings/images/example) โ€” A compact Images recipe with config and worker usage in one application path. + +- **Media Transformations** โ€” Media Transformations binding docs with local transform-chain support and clear codec fidelity boundaries. + - [Media Transformations](/docs/bindings/media-transformations) โ€” Add the Media Transformations config, call `MediaBinding` from worker code, and start with the local test path Devflare supports. + - [Media Transformations internals](/docs/bindings/media-transformations/internals) โ€” Media Transformations compiles from `bindings.media` to Wrangler `media`, with local/test behavior called out explicitly. + - [Testing Media Transformations](/docs/bindings/media-transformations/testing) โ€” Test Media Transformations by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Media Transformations example](/docs/bindings/media-transformations/example) โ€” A compact Media Transformations recipe with config and worker usage in one application path. + +- **Artifacts** โ€” Artifacts bindings for Git-compatible file storage, with in-memory repo/token tests. + - [Artifacts](/docs/bindings/artifacts) โ€” Add the Artifacts config, call `Artifacts` from worker code, and start with the local test path Devflare supports. + - [Artifacts internals](/docs/bindings/artifacts/internals) โ€” Artifacts compiles from `bindings.artifacts` to Wrangler `artifacts`, with local/test behavior called out explicitly. + - [Testing Artifacts](/docs/bindings/artifacts/testing) โ€” Test Artifacts by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Artifacts example](/docs/bindings/artifacts/example) โ€” A compact Artifacts recipe with config and worker usage in one application path. + +- **Containers** โ€” Cloudflare Containers config plus a Worker route that hands requests to a container-backed Durable Object. + - [Containers](/docs/bindings/containers) โ€” Add the Containers config, call Container class config plus a Durable Object container binding from worker code, and start with the local test path Devflare supports. + - [Containers internals](/docs/bindings/containers/internals) โ€” Containers compiles from `containers` to Wrangler `containers`, with local/test behavior called out explicitly. + - [Testing Containers](/docs/bindings/containers/testing) โ€” Test Containers by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + - [Containers example](/docs/bindings/containers/example) โ€” A compact Containers recipe with config and worker usage in one application path. + +## Full documentation + +### Why Devflare feels better than stitching Cloudflare Worker workflows together by hand + +> Devflare gives you one clearer story for config, worker compilation, local development, runtime helpers, testing, and deploy flows so a Worker app can stay small at the start and still stay coherent as it grows. + +| Field | Value | +| --- | --- | +| Route | [`/docs/what-devflare-is`](/docs/what-devflare-is) | +| Group | Quickstart | +| Navigation title | Why Devflare | +| Eyebrow | Why it helps | + +The goal is not to hide Cloudflare. The goal is to keep the files you edit small and obvious, then give you a smoother path from one worker to routing, bindings, frameworks, previews, and automation. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Teams that want Cloudflare power without accumulating setup glue | +| Architecture shape | Config, runtime, tests, framework integration, and Cloudflare ops stay separate | +| Build lane | Rolldown composes worker and Durable Object artifacts; Vite stays optional | +| Still true | Cloudflare limits and Wrangler-compatible output still matter | + +#### Why teams reach for Devflare in the first place + +Most people do not adopt Devflare because they want more abstraction. They adopt it because raw Worker projects can accumulate too many small decisions in too many places. + +Without some structure, config lives in one file, generated artifacts in another, tests invent their own fake runtime, and preview or deploy behavior becomes whichever shell snippet the team last copied forward. + +Devflare gives those pieces one authored story: readable config, worker-shaped runtime helpers, generated worker composition, a bridge-backed local loop, and deploy or preview flows that stay explicit instead of magical. + +##### Highlights + +- **Less glue code** โ€” Keep stable intent in authored config instead of scattering worker names, resource ids, and generated file edits across the repo. +- **Split by responsibility** โ€” Config authoring, runtime helpers, tests, framework hooks, and Cloudflare operations live in separate lanes instead of one catch-all surface. +- **Worker-aware compilation** โ€” Author routes, surfaces, and Durable Objects as app code, then let Devflare and Rolldown compose the runtime-facing artifacts. +- **Cleaner local and framework loop** โ€” Use one worker-aware development story that can stay worker-only or plug into Vite and SvelteKit when the package actually needs them. +- **Tests that resemble production** โ€” Reach for the built-in runtime-shaped test harness before custom mocks drift away from how the Worker actually behaves. + +#### Why the codebase stays coherent as the app grows + +The implementation splits by environment and lifecycle so the worker story can grow without collapsing into one giant tool blob. + +`devflare/config` is for authored config, `devflare/runtime` is for worker code, `devflare/test` is for harnesses, and `devflare/vite` or `devflare/sveltekit` only join the picture when the package grows into a real app host. That split is one of the package's quiet strengths. + +The build and local-dev story stays honest too. Rolldown is the worker builder, generated entrypoints keep worker surfaces explicit, and Vite or SvelteKit can sit outside the worker runtime instead of swallowing it. + +##### Highlights + +- **Split package surfaces** โ€” Different public entrypoints exist because config authoring, runtime code, tests, framework hosting, and Cloudflare operations are different jobs. +- **Rolldown owns worker artifacts** โ€” Worker and Durable Object bundles are composed and validated for Cloudflare compatibility instead of being treated as generic JavaScript output. +- **Bridge-backed framework dev** โ€” When a package uses Vite or SvelteKit, Devflare keeps Miniflare or workerd on one side, the app host on the other, and bridges bindings back into the framework dev server. +- **Framework endpoints can still reach worker bindings** โ€” In local dev, the framework lane can read Cloudflare-shaped bindings through the bridge-backed platform surface instead of needing a second fake environment. + +> **Important โ€” Vite is additive here** +> +> Vite and SvelteKit are optional outer hosts. The worker runtime, routes, bindings, and generated artifacts remain the core story. +> +> Want support for your framework of choice? [Open an issue](https://github.com/Refzlund/devflare/issues) + +#### What Devflare supports across Cloudflare platform features + +Every native binding or platform lane in the binding docs is listed here with its current Devflare support level and a direct link to the page with config, examples, tests, and boundary notes. Hover a label to see what that support level means. + +##### Highlights + +- **KV** โ€” Named config, generated types, local runtime behavior, and `createTestContext()` or `createOfflineEnv()` tests for lookup state and lightweight shared data. ([link](/docs/bindings/kv)) +- **D1** โ€” SQLite-style local behavior, id or name-based config, generated env typing, and realistic query tests through the same binding shape used in Workers. ([link](/docs/bindings/d1)) +- **R2** โ€” Object storage config, local bucket behavior, generated env typing, and runtime-shaped tests. The caveat is Cloudflare object delivery URLs, not the binding itself. ([link](/docs/bindings/r2)) +- **Durable Objects** โ€” Stateful object wiring, discovery, generated config, local namespaces, and test access, including cross-worker references. Preview lifecycle still follows Cloudflare limits. ([link](/docs/bindings/durable-objects)) +- **Queues** โ€” Producer and consumer config, local queue-trigger tests, generated env typing, and worker-surface composition for background work. ([link](/docs/bindings/queues)) +- **Services** โ€” `ref()` service bindings, typed worker-to-worker env contracts, local multi-worker runtime, and tests that call the same service binding the app uses. ([link](/docs/bindings/services)) +- **AI** โ€” Native config, generated types, deploy support, and AI Gateway method coverage are present. Real inference, model behavior, billing, and most meaningful tests remain Cloudflare remote behavior. ([link](/docs/bindings/ai)) +- **Vectorize** โ€” Native config, generated types, preview-aware resource naming, and remote-mode tests are supported. Real index semantics and similarity results require Cloudflare. ([link](/docs/bindings/vectorize)) +- **Hyperdrive** โ€” Config, name resolution, local connection strings, and Miniflare-backed Hyperdrive bindings support ordinary app queries without Cloudflare. Hosted pooling, placement, credentials, and production routing remain Cloudflare behavior. ([link](/docs/bindings/hyperdrive)) +- **Browser Rendering** โ€” Native config, generated typing, route examples, and bridge-backed dev-server support through the local browser-rendering shim. Cloudflare still owns hosted session limits, live/HITL behavior, recordings, and billing. ([link](/docs/bindings/browser-rendering)) +- **Analytics Engine** โ€” Dataset bindings are configured in Devflare, and app code can be thin-tested around `writeDataPoint()`. Production ingestion and analytics behavior remain hosted. ([link](/docs/bindings/analytics-engine)) +- **Send Email** โ€” Outbound email bindings have native config, local support, and test access through the env binding. Inbound email handlers are a separate Worker surface. ([link](/docs/bindings/send-email)) +- **Rate Limiting** โ€” Native fixed-window config, Miniflare-backed local behavior, generated typing, and pure mocks support deterministic application-level rate-limit tests. ([link](/docs/bindings/rate-limiting)) +- **Version Metadata** โ€” Native config, deterministic local metadata, and test helpers support version-aware responses and diagnostics without requiring Cloudflare state. ([link](/docs/bindings/version-metadata)) +- **Worker Loaders** โ€” Devflare wires Worker Loader bindings through Miniflare and pure test stubs, so local apps can load explicit Worker payloads without Cloudflare. Upload, discovery, and hosted lifecycle stay on the platform. ([link](/docs/bindings/worker-loaders)) +- **Secrets Store** โ€” Native config, Miniflare wiring, and explicit local fixtures cover app code that reads Secrets Store values. Devflare still does not read, provision, or sync account secret values. ([link](/docs/bindings/secrets-store)) +- **AI Search** โ€” Native instance and namespace config plus deterministic fixtures can test application flow. Crawling, indexing, ranking, and hosted model behavior stay in Cloudflare. ([link](/docs/bindings/ai-search)) +- **mTLS Certificates** โ€” Native config and Fetcher-shaped local fixtures are supported. Real client-certificate presentation and certificate lifecycle remain Wrangler and Cloudflare remote behavior. ([link](/docs/bindings/mtls-certificates)) +- **Dispatch Namespaces** โ€” Native dispatch namespace bindings and tenant Fetcher fixtures are supported. Devflare does not upload tenant Workers or emulate the Workers for Platforms control plane. ([link](/docs/bindings/dispatch-namespaces)) +- **Workflows** โ€” Native config, Miniflare workflow bindings, deterministic mocks, and real WorkflowEntrypoint examples cover the local app loop. Production lifecycle, durability, retries, and scheduling remain Cloudflare-owned. ([link](/docs/bindings/workflows)) +- **Pipelines** โ€” Native config and local send-recording tests are supported for producer code. Pipeline creation, batching, transformations, sinks, and delivery are Cloudflare-managed. ([link](/docs/bindings/pipelines)) +- **Images** โ€” Native singleton config, Miniflare image bindings, persisted local state, and deterministic pure mocks cover Worker image transform flows. Hosted storage, variants, delivery rules, billing, and final transform fidelity remain remote. ([link](/docs/bindings/images)) +- **Media Transformations** โ€” Native config, Miniflare media bindings, and deterministic pure mocks cover Worker media transform chains locally. Real codecs, output fidelity, duration handling, cache behavior, and billing remain hosted Cloudflare behavior. ([link](/docs/bindings/media-transformations)) +- **Artifacts** โ€” Native config and in-memory repo or token fixtures are supported for app flow. Durable storage, Git-over-HTTPS remotes, namespace creation, and permissions are Cloudflare-owned. ([link](/docs/bindings/artifacts)) +- **Containers** โ€” Native top-level container config has full local support through Docker or Podman: Devflare can build Dockerfile paths offline-first, run prebuilt image tags, and interact with launched instances. Deployed rollout, registry availability, SSH, scaling, and hosted platform behavior remain Cloudflare-owned. ([link](/docs/bindings/containers)) + +#### What Devflare adds on top of raw Cloudflare workflows + +These are the pieces you use while building an app, not concepts you need to memorize before the first route works. + +##### Highlights + +- **Runtime context helpers** โ€” Helper code can read the active request, env, ctx, event, and `locals` without threading the event through every function call. ([link](/docs/runtime-context)) +- **`sequence(...)` middleware** โ€” Request-wide middleware gets a named helper instead of forcing every app to reinvent the same fetch wrapper. ([link](/docs/sequence-middleware)) +- **Runtime-shaped unit testing and the smart bridge** โ€” The default test harness boots a real worker-shaped environment and uses the bridge so tests can talk to workers, bindings, queues, services, and other surfaces without inventing a second fake runtime. ([link](/docs/create-test-context)) +- **`transport.ts`** โ€” Custom bridge-backed values can round-trip as real classes instead of collapsing into plain JSON when the worker boundary needs richer types. ([link](/docs/transport-file)) +- **Multi-worker config references** โ€” `ref()` and service bindings let one worker depend on another explicitly so config, generated types, local tests, and compiled output all follow the same relationship. ([link](/docs/multi-workers)) +- **Preview scopes and preview bindings** โ€” Preview environments can get their own scoped bindings and disposable infrastructure instead of borrowing production resources and hoping everyone remembers that later. ([link](/docs/config-previews)) +- **Generated types** โ€” Generate `env.d.ts` and typed service contracts from the config so the worker surface, bindings, and entrypoints stay aligned with the app you actually run. ([link](/docs/generated-types)) +- **Binding-aware deploys** โ€” Build, preview, and production commands compile the same binding-aware config into Wrangler-compatible output instead of making you maintain a second deploy-only definition. ([link](/docs/production-deploys)) +- **`.env` config-time variables** โ€” Devflare reads `.env` while evaluating `devflare.config.*`, which keeps build-time inputs available without blurring them together with runtime `vars` and `secrets`. ([link](/docs/config-basics)) +- **Full Vite support** โ€” If the package is genuinely a Vite app, Devflare plugs into Vite as the outer host while still keeping worker-aware config, bindings, and generated Cloudflare output aligned underneath it. ([link](/docs/vite-standalone)) + +> **Tip โ€” This is the real distinction** +> +> Cloudflare gives you the platform primitives. Devflare adds the authored config model, runtime helpers, bridge-backed local dev, test harnesses, typed generation, and preview-aware workflows that make those primitives feel like one coherent application story. + +> **Important โ€” Composable infrastructure is intentional** +> +> Devflare is designed around small, explicit files and runtime surfaces: `src/fetch.ts`, `src/queue.ts`, `src/do/**/*.ts`, route modules, and runtime APIs that let those pieces compose cleanly instead of collapsing into one monolithic worker file. +> +> That same shape works for a tiny project and for a larger enterprise repo. You can keep responsibilities split by surface, file, and package without losing the thread of one coherent Cloudflare application. +> +> Want to see the package and repo shape Devflare is optimized for? [Open the project architecture guide](/docs/project-architecture) + +#### What you get on day one + +##### Steps + +1. Author one readable `devflare.config.ts` instead of reverse-engineering a generated deployment shape. +2. Point `files.fetch` at one small handler and let Devflare manage the worker-oriented plumbing around it. +3. Generate `env.d.ts` so bindings and helper surfaces stay typed without hand-maintained drift. +4. Use the built-in test harness so your first tests look like the runtime you will actually ship. +5. Add routes, bindings, frameworks, or preview flows only when the package truly needs them. + +> **Tip โ€” The point is fast confidence, not more ceremony** +> +> If Devflare is helping, your first win should be a small Worker you can understand, run, and test quickly โ€” not a larger setup burden. + +##### Example โ€” The smallest Devflare project still looks like a real project + +Two authored files teach the whole loop, while generated pieces stay visible without becoming your source of truth. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function fetch({ url }: FetchEvent): Promise { + return new Response(url.pathname === '/' ? 'Hello from Devflare' : url.pathname) +} +``` + +#### Where it keeps paying off later + +##### Key points + +- The package surface stays split by job as the app grows, so config authoring, runtime code, tests, framework hooks, and Cloudflare operations do not collapse into one file or one import path. +- Rolldown keeps owning worker and Durable Object compilation, which is why the app can grow new surfaces without hand-maintaining a giant entrypoint. +- If the package later needs Vite or SvelteKit, Devflare layers that in as an outer host and uses the bridge-backed platform surface so framework endpoints can still interact with worker bindings in local dev. +- Preview scopes, cleanup flows, production operations, and testing helpers stay connected to the same authored config and CLI instead of branching into separate half-documented workflows. + +--- + +### Build your first Devflare worker with the smallest safe setup + +> Start with one config file, one fetch handler, and generated types before you branch into routes, bindings, frameworks, or a deeper test setup. + +| Field | Value | +| --- | --- | +| Route | [`/docs/first-worker`](/docs/first-worker) | +| Group | Quickstart | +| Navigation title | Your first worker | +| Eyebrow | First setup | + +This page keeps the first pass tiny: explicit `files.fetch`, one small handler, and just enough commands to install Devflare, generate types, and run the worker locally. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | New packages and first-time Devflare users | +| Smallest safe shape | One config and one fetch handler | +| First commands | `bun add -d devflare`, then `types`, then `dev` | + +#### Get started + +##### Steps + +1. Run `bun add -d devflare`. +2. Create `devflare.config.ts` with an explicit fetch entry. +3. Add `src/fetch.ts` with one event-first handler. +4. Run `devflare types` before guessing env types by hand. +5. Run `devflare dev` and make sure the smallest worker works before you add anything else. + +##### Example โ€” Install Devflare and boot the worker + +```bash +bun add -d devflare +bunx --bun devflare types +bunx --bun devflare dev +``` + +##### Example โ€” Start with two files, not a framework maze + +Open the config first, then the fetch handler. That is enough to run, test, and understand before you add anything bigger. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function fetch({ url }: FetchEvent): Promise { + return new Response(url.pathname === '/' ? 'Hello from Devflare' : url.pathname) +} +``` + +#### Start building + +Pick the next thing you actually need once the first worker is running. + +##### Highlights + +- **Write your first unit test** โ€” Use the built-in harness before you invent mocks or wrappers. ([link](/docs/first-unit-test)) +- **Try your first bindings** โ€” Make one Durable Object, one R2 bucket, or one browser-backed route work without overcomplicating the package. ([link](/docs/first-bindings)) +- **Need multiple URLs?** โ€” Add `src/routes/**` when a route tree is easier to reason about than one large fetch handler. ([link](/docs/http-routing)) +- **Need storage choices?** โ€” Choose between KV, D1, R2, and Hyperdrive before you open the binding guide with the config and examples. ([link](/docs/storage-bindings)) +- **Need state or background work?** โ€” Use the state and async patterns page to decide between Durable Objects, queues, or a mix of both. ([link](/docs/durable-objects-and-queues)) +- **Need worker composition?** โ€” Use service bindings and `ref()` when another worker boundary is real, not just when one file feels crowded. ([link](/docs/multi-workers)) +- **Need a framework host?** โ€” Only opt into Vite-backed mode when the current package actually has a local Vite or framework app. ([link](/docs/vite-standalone)) + +--- + +### Write your first unit test with the built-in Devflare harness + +> Take the same starter worker from the previous page and add one request test through `createTestContext()` so the first check uses the same runtime shape the worker will actually run. + +| Field | Value | +| --- | --- | +| Route | [`/docs/first-unit-test`](/docs/first-unit-test) | +| Group | Quickstart | +| Navigation title | Your first unit test | +| Eyebrow | Testing | + +You do not need a custom mock stack to get confidence. Keep `devflare.config.ts` and `src/fetch.ts` as they were, add one `tests/fetch.test.ts` file, and prove the worker responds once. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | The first runtime-shaped test in a new worker package | +| Main helper | `createTestContext()` plus `cf.worker.get()` | +| First proof | One request, one status check, one response assertion | + +#### Write one honest test + +The easiest continuation from the first worker page is not a refactor. It is one new test file beside the same config and fetch handler. + +`createTestContext()` gives that test the same runtime shape Devflare manages locally. Keep the first assertion narrow: one request, one status check, one response body. That already proves the worker, the harness, and your local setup are all talking to each other correctly. + +> **Tip โ€” Keep the first test boring** +> +> If the first test is obvious, failures are obvious too. That is what you want while the worker is still tiny. + +##### Example โ€” Keep the first worker, add one test file + +The config and fetch handler stay exactly the same. The only new authored file is the test. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function fetch({ url }: FetchEvent): Promise { + return new Response(url.pathname === '/' ? 'Hello from Devflare' : url.pathname) +} +``` + +###### File โ€” tests/fetch.test.ts + +```ts +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +describe('hello-worker', () => { + test('GET / returns text', async () => { + const response = await cf.worker.get('/') + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello from Devflare') + }) +}) +``` + +#### What this unlocks next + +##### Key points + +- You can keep the same harness when the worker grows routes, queue consumers, scheduled handlers, or other runtime surfaces. +- One request-level smoke test is still useful even after helpers and abstractions appear around the worker. +- When you need more test helpers, open `/docs/create-test-context` for the full helper map. + +> **Note โ€” The next docs page when tests grow up** +> +> Use `create-test-context` when you need more than one request test and want the full runtime helper surface laid out clearly. + +--- + +### Move from one fetch file to `src/routes/**` without adding binding noise + +> The first route-tree step should only change project shape: config, request-wide middleware, one route, and one worker-level test. + +| Field | Value | +| --- | --- | +| Route | [`/docs/first-route-tree`](/docs/first-route-tree) | +| Group | Quickstart | +| Navigation title | Your first route tree | +| Eyebrow | Routing recipe | + +Do this before adding storage or remote services. It teaches the authored file shape and the route dispatch contract while the app is still small enough to debug by sight. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | The first growth step after `first-worker` | +| Files | `devflare.config.ts`, `src/fetch.ts`, `src/routes/**`, `tests/worker.test.ts` | +| Proof | `cf.worker.get()` exercises route dispatch | + +#### Copy the route tree shape + +##### Example โ€” Worker-only route tree with one test + +These files are enough to move out of a single fetch handler while keeping the runtime and test story honest. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import { locals, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId) +``` + +###### File โ€” src/routes/notes/[id].ts + +```ts +import { getFetchEvent, locals } from 'devflare/runtime' + +export async function GET(): Promise { + const event = getFetchEvent() + const id = event.params.id + + return Response.json({ + id, + requestId: locals.requestId + }) +} +``` + +###### File โ€” tests/worker.test.ts + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('route tree responds through the worker', async () => { + const response = await cf.worker.get('/api/notes/first') + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ id: 'first' }) +}) +``` + +#### Common failure messages + +##### Reference table + +| Symptom | Likely fix | +| --- | --- | +| `404 Not Found` for a route file | Check `files.routes.dir`, the route filename, and any configured prefix. | +| Ambiguous two-argument handler error | Wrap the handler with `defineFetchHandler(..., { style })` or use an event-first signature. | +| `env.dispose` is not a function | Import `env` from `devflare/test` in tests, not from `devflare/runtime`. | + +--- + +### Try your first bindings by growing the same worker one route at a time + +> Take the same starter worker, split it into routes and helpers, then add one binding-backed route at a time so `src/fetch.ts` can stay small. + +| Field | Value | +| --- | --- | +| Route | [`/docs/first-bindings`](/docs/first-bindings) | +| Group | Quickstart | +| Navigation title | Your first bindings | +| Eyebrow | Bindings | + +Keep one worker shape throughout: a tiny `src/fetch.ts`, a `src/routes/**` tree for leaf handlers, and one shared helper module that can read or write the active request context through `devflare/runtime` when that keeps the code cleaner. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Growing the first worker without turning `src/fetch.ts` into one crowded file | +| Base shape | Tiny `src/fetch.ts` plus `src/routes/**` and shared helpers | +| Habit to keep | `bunx --bun devflare types` after binding changes | + +#### Keep the same worker, but split it into routes and helpers + +The additive move after the first worker is not a different app. It is the same worker with one tiny fetch entry, one route tree, and one shared request helper. + +Once the first worker responds and maybe already has one small test, the next step is to keep `src/fetch.ts` tiny. Let it do request-wide setup, then let `src/routes/**` own the individual URLs. + +That shape also lets helper modules read the active request path, route params, request body, or request id through `getFetchEvent()` and `locals` without turning every function signature into plumbing. + +##### Highlights + +- **Durable Object** โ€” Add one counter route that forwards to one object class and keeps state there. +- **R2 bucket** โ€” Add one route that stores and reads one named file without bloating the global fetch file. +- **Browser Rendering** โ€” Add one route that opens a page and returns its title so the browser binding stays obvious. + +##### Steps + +1. Keep `src/fetch.ts` for request-wide setup only. +2. Add `files.routes` so the route tree is explicit in config. +3. Move URL-specific work into `src/routes/**` files. +4. Put shared request helpers in `src/lib/**` and let them read active request context from `devflare/runtime` when that keeps route files cleaner. +5. Add one binding-backed route at a time instead of rebuilding the worker from scratch. + +> **Tip โ€” This is still the same worker** +> +> You are not swapping architectures here. You are just letting `src/fetch.ts` stay small while routes and helpers take the extra responsibility. + +##### Example โ€” Keep the same worker, but let routes and helpers do the growing + +The fetch file stays tiny. Routes own URLs, and one helper module reads and writes the active request context through Devflare runtime when you need it. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import { sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' +import { rememberRequest } from '../lib/request-context' + +async function requestContext(event: FetchEvent, resolve: ResolveFetch): Promise { + rememberRequest() + return resolve(event) +} + +export const handle = sequence(requestContext) +``` + +###### File โ€” src/lib/request-context.ts + +```ts +import { getFetchEvent, locals } from 'devflare/runtime' + +export function rememberRequest(): void { + locals.requestId = crypto.randomUUID() +} + +export function activeRequestPath(): string { + return getFetchEvent().url.pathname +} + +export function activeRequestId(): string { + return String(locals.requestId) +} + +export function activeRouteParam(name: string): string { + return getFetchEvent().params[name] +} + +export async function activeRequestText(): Promise { + return getFetchEvent().request.text() +} +``` + +###### File โ€” src/routes/index.ts + +```ts +import { activeRequestId, activeRequestPath } from '../lib/request-context' + +export async function GET(): Promise { + return Response.json({ + message: 'Hello from Devflare', + path: activeRequestPath(), + requestId: activeRequestId() + }) +} +``` + +#### Add one Durable Object-backed route + +Keep the same route-based worker and add one counter route, one transport file, and one object class. + +Use the same `src/fetch.ts`, the same request helper, and the same route tree. The new work lives in one route file that talks to one Durable Object namespace through a custom `increment()` method. + +That keeps the route honest: the HTTP path stays in `src/routes/counter.ts`, the stateful method stays in `src/do/counter.ts`, and `src/transport.ts` restores the returned value object cleanly on the worker side. + +> **Note โ€” Why this is a good first Durable Object** +> +> It proves binding lookup, object identity, route-to-object flow, and persisted state without turning the whole worker into object-specific plumbing. + +##### Example โ€” Same worker, now add a counter route, transport, and one Durable Object + +The familiar fetch file and helper stay in place. You add the binding config, one transport file, the counter route, and the object class that exposes a custom `increment()` method. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + }, + transport: 'src/transport.ts', + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +}) +``` + +###### File โ€” src/routes/counter.ts + +```ts +import { env } from 'devflare/runtime' +import { activeRequestId, activeRequestPath } from '../lib/request-context' + +export async function GET(): Promise { + const id = env.COUNTER.idFromName('global') + const counter = env.COUNTER.get(id) + const count = await counter.increment() + + return Response.json({ + count: count.value, + double: count.double, + path: activeRequestPath(), + requestId: activeRequestId() + }) +} +``` + +###### File โ€” src/transport.ts + +```ts +import { CounterValue } from '../lib/counter-value' + +export const transport = { + CounterValue: { + encode: (value: unknown) => + value instanceof CounterValue ? value.value : false, + decode: (value: number) => new CounterValue(value) + } +} +``` + +###### File โ€” src/lib/counter-value.ts + +```ts +export class CounterValue { + value: number + + constructor(value: number) { + this.value = value + } + + get double(): number { + return this.value * 2 + } +} +``` + +###### File โ€” src/do/counter.ts + +```ts +import { DurableObject } from 'cloudflare:workers' +import { CounterValue } from '../lib/counter-value' + +export class Counter extends DurableObject { + async increment(amount: number = 1): Promise { + const count = Number((await this.ctx.storage.get('count')) ?? 0) + amount + await this.ctx.storage.put('count', count) + return new CounterValue(count) + } +} +``` + +#### Add one R2-backed route + +Keep the same worker shape and let one route file own the bucket round-trip. + +Here the route path becomes the obvious home for the binding: `src/routes/files/[name].ts` owns both the `PUT` and `GET` flow for one named object. + +The shared helper still provides request-wide context, route params, and request reads through runtime helpers, while the route file keeps the bucket usage visible and local to the URL that needs it. + +> **Important โ€” Why this is a good first R2 route** +> +> It proves route params, the bucket binding, and a clean read/write boundary without teaching a giant upload architecture before the first success. + +##### Example โ€” Same worker, now add one file route and one bucket binding + +The global fetch file stays tiny. The new work lives in one route file under `src/routes/files/[name].ts`, while the helper module still reads the active request through runtime helpers. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + }, + bindings: { + r2: { + FILES: 'quickstart-files' + } + } +}) +``` + +###### File โ€” src/routes/files/[name].ts + +```ts +import { env } from 'devflare/runtime' +import { + activeRequestPath, + activeRequestText, + activeRouteParam +} from '../../lib/request-context' + +export async function PUT(): Promise { + const key = activeRouteParam('name') + await env.FILES.put(key, await activeRequestText()) + + return Response.json({ + stored: key, + path: activeRequestPath() + }, { + status: 201 + }) +} + +export async function GET(): Promise { + const key = activeRouteParam('name') + const object = await env.FILES.get(key) + if (!object) { + return new Response('Not found', { status: 404 }) + } + + const response = new Response(object.body, { + headers: { + 'content-type': object.httpMetadata?.contentType ?? 'text/plain; charset=utf-8' + } + }) + response.headers.set('x-devflare-path', activeRequestPath()) + return response +} +``` + +#### Add one browser-backed route + +Keep the same worker shape and let one route prove the browser binding. + +Browser Rendering gets simpler when it looks like the other examples: the shared fetch file stays untouched, and one route file owns the browser work. + +Install `@cloudflare/puppeteer` before you try this route, and remember that Devflare currently supports exactly one browser binding in config. + +> **Warning โ€” Keep the first browser path skinny** +> +> One title read is enough to prove the binding. Save screenshots, PDFs, and longer browser workflows for the next pass once the launch path is already trustworthy. + +##### Example โ€” Same worker, now add one browser-backed route + +The route tree grows by one file, and the helper still gives that route access to request-scoped context without bloating `src/fetch.ts`. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes' + } + }, + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +}) +``` + +###### File โ€” src/routes/page-title.ts + +```ts +import puppeteer from '@cloudflare/puppeteer' +import { env } from 'devflare/runtime' +import { activeRequestId } from '../lib/request-context' + +export async function GET(): Promise { + const browser = await puppeteer.launch(env.BROWSER as Parameters[0]) + + try { + const page = await browser.newPage() + await page.goto('https://example.com/', { waitUntil: 'load' }) + return Response.json({ + title: await page.title(), + requestId: activeRequestId() + }) + } finally { + await browser.close() + } +} +``` + +#### Open the next page when the first quick win works + +Once one tiny example works locally, jump to the dedicated binding guides for the bigger caveats, testing patterns, and architecture choices. + +##### Highlights + +- **Durable Objects guide** โ€” Read the fuller guidance on stateful objects, migrations, previews, and local testing. ([link](/docs/bindings/durable-objects)) +- **R2 guide** โ€” Open the R2 page for delivery boundaries, testing patterns, and storage choices. ([link](/docs/bindings/r2)) +- **Browser Rendering guide** โ€” Open the browser guide when you need the single-binding caveat, dev-server details, or heavier browser workflows. ([link](/docs/bindings/browser-rendering)) + +--- + +### Deploy one preview, then delete it cleanly + +> Take the same starter worker, ship one named preview, then remove that preview scope cleanly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/deploy-and-preview`](/docs/deploy-and-preview) | +| Group | Quickstart | +| Navigation title | Deploy and Preview | +| Eyebrow | Ship it | + +The project tree does not need to become more complicated for the first deploy. Use the same small worker, one memorable preview name, and one equally explicit cleanup command. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | The first named preview deploy and cleanup loop | +| Preview command | `bunx --bun devflare deploy --preview ` | +| Cleanup command | `bunx --bun devflare previews cleanup --scope --apply` | + +#### Deploy a named preview + +Named previews are the easiest first deploy shape because the destination is obvious in the command itself and the same name can follow the preview through CI, cleanup, and review. + +If the first worker runs locally and your first test already passed, the project is ready for a simple preview loop. You do not need a new framework layer or a bigger repo ritual first. + +Pick one preview name such as `next` or `pr-123`. Then deploy with `--preview ` so the preview target is visible in your shell history and logs. + +##### Steps + +1. Finish the worker or app locally and make sure `bunx --bun devflare dev` already works. +2. Pick a preview scope name such as `next` or `pr-123`. +3. Run the explicit preview deploy command. +4. Open the preview and confirm the smallest important path works before you automate anything bigger. + +> **Tip โ€” Explicit is the point** +> +> If the command says `--preview next`, you already know where it is going. That clarity is the whole reason the CLI insists on explicit deploy targets. + +##### Example โ€” Preview-ready worker files before you deploy + +Keep the preview example anchored in the same application files a teammate would review, not only the deploy command. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + name: 'orders-api', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + ORDERS_CACHE: pv('orders-cache') + } + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function fetch(event: FetchEvent): Promise { + const url = new URL(event.request.url) + const orderId = url.pathname.split('/').at(-1) ?? 'latest' + const cacheKey = 'order:' + orderId + const cached = await event.env.ORDERS_CACHE.get(cacheKey) + + if (cached) { + return Response.json(JSON.parse(cached)) + } + + const order = { id: orderId, status: 'ready-for-preview' } + await event.env.ORDERS_CACHE.put(cacheKey, JSON.stringify(order), { expirationTtl: 300 }) + + return Response.json(order) +} +``` + +##### Example โ€” Deploy the same starter worker as a named preview + +The active file is just the command transcript. The project tree is still the same small worker from the earlier quickstart pages. + +###### File โ€” preview-command.sh + +```bash +bunx --bun devflare build --env preview +bunx --bun devflare deploy --preview next +``` + +#### Delete the preview when it is done teaching you something + +Preview cleanup should use the same scope name you deployed with. That keeps teardown reviewable and stops preview-only resources from lingering just because nobody remembers the exact branch name later. + +If the preview owns preview-only resources, `cleanup` is the quickest way to remove them. Use the exact same scope string you deployed with so the target stays unmistakable. + +If you later need richer lifecycle management, the dedicated preview operations docs cover scope inspection, cleanup planning, and broader cleanup runs. For the first loop, resource cleanup is enough to understand the shape. + +##### Key points + +- Reuse the same preview scope name you deployed with. +- Keep cleanup commands explicit so logs clearly show what is being removed. +- If the preview becomes a real recurring workflow, move that command into CI instead of relying on team memory. + +> **Warning โ€” Delete previews explicitly too** +> +> Preview environments get messy when deploys are automated but cleanup rules live only in peopleโ€™s heads. Use the same explicit naming discipline for teardown that you used for deploy. + +##### Example โ€” Clean up the same named preview + +The cleanup command should feel like the mirror image of the deploy command: same project, same scope name, same explicit target. + +###### File โ€” cleanup-preview.sh + +```bash +bunx --bun devflare previews cleanup --scope next --apply +``` + +#### What to read next + +Once the first preview loop works, jump to production deploy rules and GitHub automation. + +When this local preview loop is ready to leave your shell history and become reviewable automation, continue with `github-workflows`. That page maps the exact `.github/workflows/*.yml` files this repo uses for PR comments, branch previews, production deploys, and cleanup. + +##### Highlights + +- **Production deploys** โ€” Read the production guide for explicit targets, preflight checks, and deploy inspection habits. ([link](/docs/production-deploys)) +- **GitHub workflows** โ€” Continue with the repo-backed workflow guide when you want this preview loop to become PR comments, branch previews, production deploys, and cleanup jobs under `.github/workflows`. ([link](/docs/github-workflows)) + +--- + +### Treat `devflare` as one documented CLI, not a bag of one-off shell snippets + +> Start at `devflare --help`: the root page already maps local dev, inspection, deploy intent, account inventory, preview lifecycle, production control, token management, AI pricing, and remote-mode operations in one place. + +| Field | Value | +| --- | --- | +| Route | [`/docs/devflare-cli`](/docs/devflare-cli) | +| Group | Devflare | +| Navigation title | CLI | +| Eyebrow | Command surface | + +Devflareโ€™s CLI is the public control surface for the same authored config model the docs site describes. Most packages live in the boring `types โ†’ dev โ†’ build โ†’ deploy` loop, but the CLI also owns the surrounding control plane. Learn the root commands once, then drill into `devflare help ` or nested `--help` pages when one family goes deeper. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Everyday dev, config inspection, explicit deploys, and the Cloudflare control-plane work around those deploys | +| Fastest orientation | `bunx --bun devflare --help` | +| Help depth | `devflare help [subcommand]` | +| Safest habit | Run commands from the package that owns the `devflare.config.*` you mean to resolve | + +#### Start with the root help page, then drill down + +The root help page is not just a banner and a couple of examples. It is the best quick map of the whole CLI: core dev commands, deploy intent, inspection tools, and the deeper control-plane families all show up there first. + +From there, the CLI keeps the same shape all the way down. `devflare help deploy` and `devflare deploy --help` resolve to the same detailed guide, and nested families such as `previews` or `productions` keep going with their own subcommand help instead of forcing you to remember a maze of ad-hoc commands. + +##### Key points + +- Use the root help first when you are not sure which command family owns the job. +- Use command-specific help when the job is already obvious but the option vocabulary is not. +- Use nested help for the control-plane families that have real subcommand trees instead of pretending one page can explain them all. + +> **Note โ€” The docs page should mirror the help tree** +> +> If the built-in help already describes the command surface cleanly, the docs page should explain that structure instead of flattening everything back into four example commands. + +##### Example โ€” Use the built-in help tree as the CLI map + +```bash +bunx --bun devflare --help +bunx --bun devflare help deploy +bunx --bun devflare previews --help +bunx --bun devflare previews cleanup --help +bunx --bun devflare productions rollback --help +``` + +#### Know what each root command family owns + +##### Reference table + +| Command | Primary job | What the deeper help covers | +| --- | --- | --- | +| `init` | Scaffold a new package. | Template choice and generated starter scripts. | +| `dev` | Start local development. | Worker-only defaults, Vite auto-detection, `ref()` service workers, runtime-port selection, logging, and persistence. | +| `build` | Compile deploy-ready artifacts. | Environment resolution and Wrangler-facing output. | +| `deploy` | Ship explicitly to production or preview. | Target selection, dry runs, preview naming, messages, and tags. | +| `types` | Generate `env.d.ts` and typed bindings. | Custom output paths plus entrypoint and Durable Object discovery. | +| `doctor` | Check local project health. | Config, package, TypeScript, Vite, scope-aware local/deploy artifact diagnostics, and optional plugin guidance. | +| `config` | Print resolved config. | `print`, raw Devflare JSON, compiled Wrangler JSON, and build/local/deploy resolution phases. | +| `account` | Inspect Cloudflare account inventories and limits. | Resource lists, usage limits, and interactive global/workspace selection. | +| `login` | Authenticate with Cloudflare via Wrangler. | `--force` behavior and reuse of existing sessions. | +| `previews` | Operate on preview lifecycle state. | `list`, `bindings`, and `cleanup`. | +| `productions` | Inspect and mutate live production state. | `versions`, `rollback`, and `delete`. | +| `worker` | Run Worker control-plane operations. | Currently `rename`, plus config-sync expectations. | +| `tokens` | Manage Devflare-managed account-owned API tokens. | List, create, roll, and delete managed tokens. | +| `ai` | Print the bundled Workers AI pricing snapshot. | Read-only pricing surface; verify current rates in Cloudflare docs when it matters. | +| `remote` | Toggle remote test mode for paid features. | `status`, `enable`, and `disable`. | +| `help` | Render root or command-specific help. | Nested help resolution for command families and subcommands. | +| `version` | Print the installed version. | Same information as the global `--version` flag. | + +#### Learn the shared option vocabulary once + +The root help page also teaches the common option vocabulary. That matters because not every command supports every option, but the meaning stays consistent when the option exists. + +If you already know what `--config`, `--env`, `--debug`, and `--help` mean, the command-specific help pages get much easier to scan. + +##### Key points + +- `--env` is meaningful only on commands that actually resolve config environments. +- `--help` is not a fallback after confusion; it is the intended first stop for a new command family. +- When in doubt about which config file is being resolved, make `--config` explicit instead of trusting directory luck. + +##### Reference table + +| Option | What it means | Where it matters most | +| --- | --- | --- | +| `--config ` | Pick the exact `devflare.config.*` file to resolve. | `build`, `deploy`, `types`, `doctor`, `config`, `previews`, `productions`, and `worker rename`. | +| `--env ` | Resolve `config.env[name]` before the command runs. | `build`, `config`, preview-aware inspection, and production discovery flows. | +| `--debug` | Print stack traces and extra debug output. | Build, deploy, type generation, and other failure-heavy paths. | +| `--no-color` | Disable ANSI color output. | CI logs, copied transcripts, or plain-text debugging. | +| `-h, --help` | Show the detailed help page for the current command path. | Every root command and nested subcommand surface. | +| `-v, --version` | Print the installed version and exit. | Root invocation when you need to verify the installed package quickly. | + +#### Use the root page as the map, then let deeper pages own the sharp edges + +The root CLI page should tell you which family exists and what it is broadly for. Once a command starts operating on preview lifecycle, live production, account context, tokens, or paid-test gates, the sharper behavior belongs on the dedicated operations pages instead of being re-explained here in parallel. + +Use the built-in help for exact flags, then use the docs pages below for the operational safety rules and workflow context around those command families. + +##### Highlights + +- **Control-plane operations** โ€” Open this page for account selection, live production inspection, rollback or delete posture, worker rename, token bootstrap, and remote-mode gates. ([link](/docs/control-plane-operations)) +- **devflare/cloudflare** โ€” Open this page when a script or tool should use the same account, registry, usage, and token helpers the CLI builds on. ([link](/docs/cloudflare-api)) +- **Preview operations** โ€” Open this page when the question is preview registry inspection or resource cleanup. ([link](/docs/preview-operations)) +- **Production deploys** โ€” Open this page when the question is the deploy target and preflight inspection rather than later control-plane changes. ([link](/docs/production-deploys)) + +##### Key points + +- Use `account`, `productions`, `worker`, `tokens`, and `remote` when you are operating real Cloudflare state instead of just building locally. +- Use `previews` when the job is preview lifecycle rather than day-to-day package development. +- Treat nested `--apply` flows as command families that deserve both built-in help and the dedicated docs page before you run them. + +> **Warning โ€” The sharp edges live one level deeper** +> +> `previews cleanup`, `productions rollback`, and `productions delete` all carry behavior and safety notes that are too specific for the root CLI map. Read their help and the dedicated docs page before treating them as copy-paste habits. + +#### Most packages still live in one boring, reliable command loop + +The most useful Devflare loop is intentionally repetitive: refresh generated types when bindings move, run local dev, inspect build output when the shape changes, and deploy with an explicit preview or production target. + +That loop stays the same whether the package is worker-only or Vite-backed. The config decides the host; the command vocabulary stays familiar. + +When the job changes from building to operating, switch command families instead of inventing ad-hoc command snippets: `config` and `doctor` for inspection, `previews` for preview lifecycle, `productions` for live production state, and `account` for inventory questions. + +##### Key points + +- Run `types` after binding or entrypoint changes so `env.d.ts` stays honest. +- Use `dev --runtime-port ` or `DEVFLARE_RUNTIME_PORT` when another local project already owns the default 8787 runtime port. +- Use `config --phase local --format wrangler` when you want local config inspection without Cloudflare account lookups. +- Use `ref()` service bindings for local full-stack packages; Devflare starts those referenced workers in CLI dev and exposes them as Vite auxiliary workers for framework dev. +- Run `build` or `config print --format wrangler` when the compiled shape matters more than the dev server feeling healthy. +- Use `doctor --scope local` when generated deploy artifacts are intentionally absent during a local-only loop. +- Keep preview and production intent explicit in the final deploy command instead of hiding it in a generic script name. +- Use the nested help pages when a lifecycle command reaches `--apply`, account selection, rollback, or cleanup territory. + +##### Example โ€” Map the everyday CLI loop into package scripts + +Keep scripts thin and explicit so local developers and CI both call the same Devflare command surface. + +###### File โ€” package.json + +```json +{ + "scripts": { + "dev": "devflare dev", + "types": "devflare types", + "build": "devflare build --env staging", + "deploy:preview": "devflare deploy --preview next", + "deploy:prod": "devflare deploy --prod", + "doctor": "devflare doctor" + } +} +``` + +##### Example โ€” A good everyday command loop + +```bash +bunx --bun devflare types +bunx --bun devflare dev +bunx --bun devflare build --env staging +bunx --bun devflare deploy --preview next +bunx --bun devflare deploy --prod +``` + +##### Example โ€” When the setup feels suspicious, inspect before you improvise + +```bash +bunx --bun devflare config print --format wrangler +bunx --bun devflare config --phase local --format wrangler +bunx --bun devflare doctor --scope local +bunx --bun devflare previews bindings --scope next +bunx --bun devflare productions versions +``` + +#### Use the inspection and lifecycle commands before you improvise command snippets + +##### Highlights + +- **`config print`** โ€” Best when you need to see the resolved Devflare config or compiled Wrangler-facing shape before trusting a build or deploy. +- **`doctor`** โ€” Best when config resolution, generated artifacts, or local Vite detection feel hard to trace and need a sharper diagnostic pass. +- **`previews` / `productions`** โ€” Best when the question is no longer โ€œcan I deploy?โ€ but โ€œwhat exists right now, and what should I clean up, roll back, or inspect?โ€ + +> **Warning โ€” Keep commands package-local** +> +> Run Devflare from the package that owns the config you actually mean to resolve. In monorepos, Turbo can decide what changed, but package-local `devflare` commands still decide what gets built, deployed, inspected, or cleaned up. + +--- + +### Structure Devflare projects around one authored config, explicit runtime files, and package-local deploy ownership + +> This is the practical answer to โ€œwhat does a real Devflare project look like on disk?โ€ โ€” from a small worker package, to a multi-surface app, to a hosted SvelteKit package, to a Bun monorepo with several deployable workers. + +| Field | Value | +| --- | --- | +| Route | [`/docs/project-architecture`](/docs/project-architecture) | +| Group | Devflare | +| Navigation title | Project Architecture | +| Eyebrow | Project setup | + +Devflare projects stay readable when the package boundary is obvious, the authored files stay separate from generated output, and each runtime surface owns its own file. This page maps the common file types, then shows a few real project shapes from this repository so you can set up your package deliberately instead of accumulating conventions by accident. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Teams deciding how to lay out a new Devflare package or a multi-package workspace before file structure gets noisy | +| Primary authored file | `devflare.config.ts` in each deployable package | +| Generated files | `env.d.ts`, `.devflare/**`, and `.wrangler/deploy/**` | +| Monorepo rule | Validate from the root, but deploy from the package that owns the config | + +#### Start with authored files, and treat generated files as output + +The first architecture decision is not โ€œwhich framework?โ€ It is usually โ€œwhich files in this package are actually authored source of truth?โ€ In Devflare, the stable answer is that `devflare.config.ts`, `package.json`, and your runtime files are authored; generated Wrangler-facing files and generated types are downstream outputs. + +That split is what keeps the project reviewable. If a file describes package intent or runtime behavior, author it directly. If a file is emitted by Devflare, a framework adapter, or Wrangler preparation, treat it as disposable output and regenerate it when the source changes. + +##### Reference table + +| Path or pattern | Own it when | What it means | +| --- | --- | --- | +| `devflare.config.ts` | Every deployable package | The authored Devflare source of truth for files, bindings, env overlays, previews, and deployment posture. | +| `package.json` | Every package | Package-local scripts, dependencies, and the command loop that should run from that package. | +| `src/fetch.ts` | The package owns request-wide HTTP behavior | The main worker entry for broad middleware or request handling. | +| `src/routes/**` | The package uses file-based HTTP leaves | URL-specific route handlers that sit beside, or replace, one large fetch file. | +| `src/queue.ts`, `src/scheduled.ts`, `src/email.ts` | The package consumes those platform events | Separate event surfaces instead of burying background logic inside fetch code. | +| `src/do/**/*.ts` | The package owns Durable Object classes | Stateful classes discovered and bundled through config. | +| `src/ep/**/*.ts` | The package exposes named worker entrypoints | Classes discovered for typed `ref().worker(...)` service boundaries. | +| `src/workflows/**/*.ts` | The package owns workflow definitions | Additional discovered runtime modules that stay explicit in config review. | +| `src/transport.ts` | Local RPC-style bridge calls must preserve custom values | Custom encode/decode rules for local bridge-backed calls, most often in tests or Durable Object method round-trips. | +| `env.d.ts` | You run `devflare types` | Generated binding and entrypoint types. Do not hand-edit it. | +| `vite.config.ts`, `svelte.config.js`, `src/routes/+page.svelte` | The package is a hosted Vite or SvelteKit app | Host-app files that sit around the Devflare worker story instead of replacing it. | +| `.devflare/**`, `.wrangler/deploy/**` | Devflare has built, checked, or prepared deploy output | Generated build and deploy artifacts. Useful to inspect, not the authored architecture. | + +> **Tip โ€” A good architecture rule** +> +> If the file describes package intent, author it. If the file exists because Devflare or a host tool generated it, inspect it when needed but keep the authored source elsewhere. + +#### A worker-first package can stay small for a long time + +A healthy Devflare package can start with one config file, one `src/fetch.ts`, one route tree, and one small test. That already gives you package-local scripts, generated types, generated deploy output, and room to grow without forcing a framework or a monorepo strategy on day one. + +The point of this shape is not minimalism for its own sake. It is that the package boundary stays obvious: the package owns its config, owns its worker files, and can be built or deployed without pretending the whole repo is one worker. + +##### Key points + +- Keep the package-local command loop in `package.json` so `types`, `dev`, `build`, and `deploy` always resolve the right config. +- Keep `src/fetch.ts` request-wide and let `src/routes/**` own the URL-specific work once there is more than one leaf. +- Expect `env.d.ts`, `.devflare/**`, and `.wrangler/deploy/**` to appear as generated outputs after the normal command loop runs. + +##### Example โ€” Small worker package with one config, one fetch file, one route tree, and generated output kept in its lane + +###### File โ€” package.json + +```json +{ + "name": "notes-api", + "private": true, + "type": "module", + "scripts": { + "types": "bunx --bun devflare types", + "dev": "bunx --bun devflare dev", + "build": "bunx --bun devflare build", + "deploy": "bunx --bun devflare deploy" + }, + "devDependencies": { + "devflare": "workspace:*" + } +} +``` + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +}) +``` + +###### File โ€” src/fetch.ts + +```ts +import { sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId) +``` + +###### File โ€” src/routes/health.ts + +```ts +export async function GET(): Promise { + return Response.json({ ok: true }) +} +``` + +#### One package can own many runtime files without becoming a monolith + +This is where Devflare architecture becomes more interesting than โ€œone fetch file.โ€ A single package can still own HTTP, route modules, queue work, scheduled jobs, email handlers, Durable Objects, named entrypoints, workflows, and transport rules โ€” as long as each surface keeps its own file and the config names those surfaces explicitly. + +The `files.*` lane matters for this reason. It is the map of which runtime surfaces the package actually owns. + +##### Reference table + +| File lane | Why it exists | +| --- | --- | +| `src/fetch.ts` | Request-wide middleware and the outer HTTP trail. | +| `src/routes/**` | Leaf handlers that mirror URLs instead of bloating the global fetch file. | +| `src/queue.ts`, `src/scheduled.ts`, `src/email.ts` | Background and platform-triggered event surfaces with their own runtime contracts. | +| `src/do/**/*.ts` | Stateful Durable Object classes discovered and bundled through config. | +| `src/ep/**/*.ts` | Named worker entrypoints for typed cross-worker boundaries. | +| `src/workflows/**/*.ts` | Workflow definitions discovered as part of the package runtime shape. | +| `src/transport.ts` | Local bridge serialization only when custom values need to survive a bridge-backed call. | + +> **Warning โ€” Not every package should own every file type** +> +> The point is explicit ownership, not maximal surface area. Add each runtime file only when the package really owns that event or discovery lane. + +##### Example โ€” A single package with all the main worker-owned file types visible on disk + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workspace-app', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts', + entrypoints: 'src/ep/**/*.ts', + workflows: 'src/workflows/**/*.ts', + transport: 'src/transport.ts' + }, + bindings: { + durableObjects: { + SESSION_ROOM: 'SessionRoom' + }, + queues: { + producers: { + EMAILS: 'workspace-emails' + }, + consumers: [ + { + queue: 'workspace-emails' + } + ] + } + }, + triggers: { + crons: ['0 */6 * * *'] + } +}) +``` + +###### File โ€” src/queue.ts + +```ts +import type { QueueEvent } from 'devflare/runtime' + +export async function queue({ messages }: QueueEvent): Promise { + for (const message of messages) { + console.log('processing job', message.id) + } +} +``` + +###### File โ€” src/do/session-room.ts + +```ts +import { DurableObject } from 'cloudflare:workers' + +export class SessionRoom extends DurableObject { + async fetch(request: Request): Promise { + return new Response('room:' + new URL(request.url).pathname) + } +} +``` + +#### Hosted apps add Vite or SvelteKit around the worker, not instead of it + +The docs app in this repo is the simplest real example of a hosted package: it has `package.json`, `devflare.config.ts`, `vite.config.ts`, `svelte.config.js`, Svelte route files, and static assets. Devflare still owns the Cloudflare-facing config and generated Wrangler output, while Vite and SvelteKit own the host-app shell. + +The repo also includes a fuller SvelteKit case that points `files.fetch` at the generated Cloudflare worker output while still discovering Durable Objects and transport hooks from source. That is the important hosted-app lesson: the framework shell and the worker surfaces can coexist in one package when the file ownership stays explicit. + +##### Key points + +- Package-local host files like `vite.config.ts` and `svelte.config.js` belong beside the Devflare config, not in a separate orchestration package. +- Hosted apps can point at generated framework worker output, or they can mix that output with extra Devflare-owned surfaces like Durable Objects and transport hooks. +- The generated worker file still belongs on the generated side of the boundary; the authored source remains the config plus the source files that feed it. + +##### Example โ€” Real hosted app package from `apps/documentation` + +###### File โ€” apps/documentation/package.json + +```json +{ + "name": "documentation", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run llm:generate && bunx --bun devflare dev", + "build": "bun run llm:generate && bunx --bun devflare build", + "deploy": "bun run llm:generate && bunx --bun devflare deploy", + "types": "bunx --bun devflare types" + }, + "devDependencies": { + "devflare": "workspace:*", + "vite": "^8", + "@sveltejs/kit": "^2" + } +} +``` + +###### File โ€” apps/documentation/devflare.config.ts + +```ts +import { defineConfig } from '../../packages/devflare/src/config-entry' + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + +export default defineConfig({ + name: 'devflare-docs', + compatibilityDate: '2026-04-08', + files: { + fetch: false + }, + previews: { + includeCrons: false + }, + accountId, + assets: { + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }, + wrangler: { + passthrough: { + main: '.adapter-cloudflare/_worker.js' + } + } +}) +``` + +###### File โ€” apps/documentation/vite.config.ts + +```ts +import { sveltekit } from '@sveltejs/kit/vite' +import { devflarePlugin } from '../../packages/devflare/src/vite/index' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + devflarePlugin(), + sveltekit() + ] +}) +``` + +##### Example โ€” Hosted SvelteKit package that still owns extra worker surfaces + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'case18-sveltekit-full', + files: { + fetch: '.svelte-kit/cloudflare/_worker.js', + durableObjects: 'src/do.*.ts', + transport: 'src/transport.ts' + }, + bindings: { + r2: { + IMAGES: 'images-bucket' + }, + d1: { + DB: 'main-db' + }, + durableObjects: { + CHAT_ROOM: { + className: 'ChatRoom' + } + } + } +}) +``` + +#### In a monorepo, Turbo orchestrates the workspace but packages still deploy themselves + +This repository is the monorepo example. The root owns workspace scripts, workspaces, and Turbo task orchestration. But deployable packages still keep their own `devflare.config.ts` files and package-local commands. That is true for `apps/documentation`, `apps/testing`, sidecar workers under `apps/testing/workers/*`, and the smaller cases under `cases/*`. + +That split is what keeps the monorepo honest. Root scripts decide what to validate or cache. Package-local Devflare commands decide what actually resolves, builds, deploys, or cleans up. + +##### Steps + +1. Use the repo root for Turbo build, test, check, and impacted-package orchestration. +2. Run `devflare` from the package that owns the config you actually mean to resolve. +3. Keep sidecar workers or service-bound packages as separate workspace packages with their own configs and scripts. +4. Reuse one preview scope across a worker family only after you have made the package boundaries explicit. + +> **Warning โ€” Turbo is not the deploy target** +> +> Turbo decides which packages need work. The package working directory still decides which `devflare.config.ts` gets built or deployed. + +##### Example โ€” The repo root orchestrates, but the packages still own deployment + +###### File โ€” package.json + +```json +{ + "name": "devflare-monorepo", + "private": true, + "workspaces": [ + "apps/*", + "apps/testing/workers/*", + "packages/*", + "cases/*" + ], + "scripts": { + "devflare:build": "turbo run build --filter=devflare --filter=documentation", + "devflare:test": "turbo run test --filter=...devflare", + "devflare:check": "turbo run check --filter=documentation", + "devflare:ci": "bun run devflare:build && bun run devflare:test && bun run devflare:check" + } +} +``` + +###### File โ€” turbo.json + +```json +{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".devflare/**", ".wrangler/deploy/**", "env.d.ts"] + }, + "test": { + "dependsOn": ["^build", "transit"] + }, + "check": { + "dependsOn": ["^build", "transit"] + } + } +} +``` + +###### File โ€” apps/testing/workers/auth-service/devflare.config.ts + +```ts +import { defineConfig } from '../../../../packages/devflare/src/config-entry' + +export default defineConfig({ + name: 'devflare-testing-auth-service', + files: { + fetch: 'src/worker.ts' + } +}) +``` + +##### Example โ€” Good monorepo command split + +```bash +# repo-root orchestration +bun run turbo build --filter=documentation +bun run devflare:check + +# package-local deploy +cd apps/documentation +bun run deploy -- --preview next + +# sidecar worker family +cd ../testing/workers/auth-service +bunx --bun devflare deploy --preview pr-123 +``` + +#### Open the deeper page for the part of the architecture you are deciding next + +##### Highlights + +- **Need the file-surface rules?** โ€” Open project shape when the next question is how many surfaces the package should actually own and which conventions should stay explicit. ([link](/docs/project-shape)) +- **Need the event-surface map?** โ€” Open worker surfaces when the real question is fetch versus queue versus scheduled versus email, or when the package has started owning more than one event family. ([link](/docs/worker-surfaces)) +- **Need route layout next?** โ€” Open the routing page when the package boundary is clear and the next decision is how `src/fetch.ts` and `src/routes/**` should split responsibility. ([link](/docs/http-routing)) +- **Need generated types and entrypoints?** โ€” Open generated types when the architecture includes bindings, named entrypoints, service refs, or Durable Objects that should land in `env.d.ts` accurately. ([link](/docs/generated-types)) +- **Need the fuller monorepo workflow?** โ€” Open the monorepo page when the next question is Turbo filters, CI workflow boundaries, or package-local deploy discipline across the workspace. ([link](/docs/monorepo-turborepo)) + +--- + +### Keep bridge architecture documentation behind advanced/internal links + +> The bridge architecture document remains valuable, but it should not be on the first-hour developer path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bridge-architecture-internals`](/docs/bridge-architecture-internals) | +| Group | Devflare | +| Navigation title | Bridge internals | +| Eyebrow | Internal architecture | + +Link the bridge architecture doc only from advanced runtime, transport, or maintainer pages. Beginner docs should show recipes first and link internals after the developer already has a working example. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Canonical file | `packages/devflare/.docs/BRIDGE_ARCHITECTURE.md` | +| Audience | Maintainers and advanced runtime debugging | +| Linked from | Transport and project architecture docs | + +#### Read this after the recipe path works + +##### Key points + +- You are debugging the bridge transport or local runtime startup. +- You are changing how local RPC, Durable Objects, service bindings, or framework platform glue cross the worker boundary. +- You need maintainer context, not first-run setup instructions. + +##### Example โ€” A bridge-backed value that needs a transport file + +###### File โ€” src/domain/Money.ts + +```ts +export class Money { + constructor( + readonly amount: number, + readonly currency: string + ) {} + + format(): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: this.currency + }).format(this.amount) + } +} +``` + +###### File โ€” src/transport.ts + +```ts +import { Money } from './domain/Money' + +export const transport = { + Money: { + encode: (value: unknown) => + value instanceof Money + ? { amount: value.amount, currency: value.currency } + : false, + decode: (value: { amount: number; currency: string }) => + new Money(value.amount, value.currency) + } +} +``` + +###### File โ€” src/do/invoices.ts + +```ts +import { Money } from '../domain/Money' + +export class Invoices extends DurableObject { + async total(customerId: string): Promise { + const key = 'invoice:' + customerId + ':total' + const stored = await this.ctx.storage.get(key) + return new Money(stored ?? 0, 'USD') + } +} +``` + +--- + +### Split request-wide middleware from route leaves so HTTP stays easy to read + +> Use `src/fetch.ts` for request-wide behavior, `src/routes/**` for leaf handlers, and `files.routes` when you need a custom root, prefix, or route-only app. + +| Field | Value | +| --- | --- | +| Route | [`/docs/http-routing`](/docs/http-routing) | +| Group | Devflare | +| Navigation title | Routing | +| Eyebrow | HTTP layer | + +Devflare gives you a request-wide fetch entry and a built-in file router. The safest mental model is simple: keep broad middleware in `src/fetch.ts`, keep URL-specific behavior in `src/routes/**`, and reach for `files.routes` when the route tree needs custom mounting rules. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | HTTP apps that need middleware, route params, or a mounted route tree | +| Primary order | `src/fetch.ts` โ†’ same-module methods โ†’ matched route file | +| Route config | `files.routes` | + +#### Two HTTP layers by design + +If `src/fetch.ts` exports `fetch` or `handle`, that module becomes the primary HTTP entry. Inside `resolve(event)`, Devflare checks same-module method handlers first and then dispatches to the matched route file when needed. + +That ordering is what lets middleware stay global while route files remain the clean leaf-handler story. + +##### Highlights + +- **`src/fetch.ts`** โ€” Use it for request-wide behavior that should apply before or after the final leaf handler runs. +- **`src/routes/**`** โ€” Use it for specific URL handlers so the file tree mirrors the URLs you serve. + +##### Steps + +1. Devflare enters through `src/fetch.ts` when that file exports `fetch` or `handle`. +2. Inside `resolve(event)`, exact same-module HTTP method handlers such as `GET` or `POST` are checked first, `HEAD` falls back to `GET` with an empty body, and `ALL` is the last module-local fallback. +3. If no same-module method handler answers the request, Devflare falls through to the matched route file. +4. Devflare computes route params before request-wide middleware continues, so `event.params` is available to both outer middleware and the leaf handler. + +#### Use middleware for broad concerns, not leaf business logic + +> **Warning โ€” Keep the split clean** +> +> If a piece of logic only matters for one URL, it probably belongs in a route file, not in global middleware. + +##### Example โ€” Keep the middleware file and the leaf route side by side + +The global file owns request-wide behavior. The route file owns one URL. When those stay separate, the whole HTTP layer stays readable. + +###### File โ€” src/fetch.ts + +```ts +import { sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' + +async function cors(event: FetchEvent, resolve: ResolveFetch): Promise { + if (event.request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + }) + } + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('Access-Control-Allow-Origin', '*') + return next +} + +export const handle = sequence(cors) +``` + +###### File โ€” src/routes/users/[id].ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function GET(event: FetchEvent): Promise { + return Response.json({ id: event.params.id }) +} +``` + +#### Route-only apps are valid when you do not need global middleware + +You do not need `src/fetch.ts` just to use the file router. If every concern is leaf-local, a route tree on its own is a clean supported shape. + +That is especially useful for small APIs where a mounted route prefix matters more than request-wide middleware. + +> **Note โ€” Start route-only when the app really is route-only** +> +> Skip `src/fetch.ts` until you genuinely need request-wide auth, logging, CORS, or response shaping. Add the global file later; the route tree stays valid. + +##### Example โ€” Mount a route tree under `/api` without a `src/fetch.ts` file + +Explicit `files.routes` keeps the route root and prefix obvious in code review while the app stays route-only. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'users-api', + files: { + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +}) +``` + +###### File โ€” src/routes/users/[id].ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ params }: FetchEvent): Promise { + return Response.json({ id: params.id }) +} +``` + +#### Use `files.routes` to remap, prefix, or disable the route tree + +`files.routes` is app routing config. It controls how Devflare discovers and mounts route modules inside the Worker package. + +It is not the same thing as top-level Cloudflare deployment `routes`, which decide which hostnames and path patterns reach the Worker in the first place. + +##### Reference table + +| Shape | What it does | +| --- | --- | +| Omit `files.routes` | `src/routes` is auto-discovered when that directory exists. | +| `{ dir: 'app-routes' }` | Changes the route root without changing the rest of the routing model. | +| `{ dir: 'src/routes', prefix: '/api' }` | Mounts discovered routes under a fixed prefix such as `/api`. | +| `false` | Disables file-route discovery entirely. | + +> **Warning โ€” Do not blur app routing and deployment routing** +> +> If you are choosing files inside your Worker, you want `files.routes`. If you are deciding which traffic reaches the Worker at all, you want top-level Cloudflare `routes`. + +#### Specificity and guardrails matter once the tree grows + +##### Key points + +- Static routes beat dynamic routes, dynamic routes beat rest routes, and optional rest routes are checked last. +- `src/routes/users/[id].ts` and `src/routes/users/[slug].ts` normalize to the same pattern and are rejected as conflicts. +- Files or directories beginning with `_` are ignored so route-local helpers can live beside handlers. +- `HEAD` falls back to `GET` if you do not export a dedicated `HEAD` handler. +- Route modules can use HTTP method exports, or a primary `fetch` / `handle` export, just like the fetch module. + +##### Reference table + +| Filename | Meaning | +| --- | --- | +| `src/routes/index.ts` | Matches `/`. | +| `src/routes/users/[id].ts` | Matches `/users/:id` and exposes `event.params.id`. | +| `src/routes/blog/[...slug].ts` | Matches one-or-more trailing segments and exposes `slug` as joined path text. | +| `src/routes/docs/[[...slug]].ts` | Matches both the directory root and deeper optional rest paths. | + +> **Important โ€” Conflict errors are a feature, not a nuisance** +> +> If two files normalize to the same route pattern, Devflare rejects the tree instead of guessing. That makes route review boring in the best possible way. + +--- + +### Author stable config, keep secrets and generated output in their own lanes + +> Write `devflare.config.ts` for humans first, let Devflare merge environments and resolve names later, and treat generated Wrangler-facing files as outputs rather than authoring surfaces. + +| Field | Value | +| --- | --- | +| Route | [`/docs/config-basics`](/docs/config-basics) | +| Group | Devflare | +| Navigation title | Config basics | +| Eyebrow | Configuration | + +The easiest way to keep Devflare predictable is to keep stable intent in authored config and let build or deploy flows resolve the noisy details. That applies to environment overlays, stable resource names, secrets, and generated output. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Anyone authoring or reviewing `devflare.config.ts` | +| Source of truth | Authored config plus source files | +| Escape hatch | `wrangler.passthrough` | + +#### A simple config flow + +##### Steps + +1. Author stable intent in `devflare.config.ts`. +2. Optionally merge a named Devflare environment with `--env `. +3. Resolve account ids or resource ids only in flows that truly need them. +4. Emit Wrangler-compatible output as generated artifacts. +5. Build or deploy from generated output without hand-editing it. + +> **Note โ€” If a generated file feels hand-maintained, move the intent back up** +> +> That usually means the authored config is missing a real source-of-truth value or needs a passthrough key. + +#### Keep vars, secrets, and `.env` separate + +Devflare prefers a workspace-root `.env` when it finds a workspace ancestor; otherwise it falls back to the nearest ancestor `.env` before evaluating config. That is useful for config-time values, but it does not make `.dev.vars*` the source of truth for worker-only dev or tests. + +Stable infrastructure names belong in authored config. Do not hide them in secrets just because another tool happens to like environment variables. + +##### Reference table + +| Layer | Use it for | +| --- | --- | +| `vars` | String config that compiles into generated Wrangler output. | +| `secrets` | Declaring which runtime secret bindings should exist. The schema accepts `{ required: false }`, but generated env typing still treats declared secrets as present either way today. | +| `.env` | Inputs used while evaluating `devflare.config.*` at config time. | +| `.env.example` | Documenting config-time variables for the team. | + +#### Generated artifacts are outputs, not contracts + +`wrangler.passthrough` is a shallow top-level override. Use it when Devflare does not model a Wrangler key yet, not as a place to mirror the whole generated config by habit. + +Devflare only generates `.devflare/worker-entrypoints/main.ts` when it needs to wrap or compose the worker surfaces it discovered. If `wrangler.passthrough.main` is set, or the fetch worker already lives at `assets.directory/_worker.js`, Devflare can skip that generated main entry and use the explicit worker instead. + +##### Key points + +- `.devflare/wrangler.jsonc` +- `.devflare/build/wrangler.jsonc` +- `.devflare/worker-entrypoints/main.ts` and `.js` when Devflare needs wrapper glue around the worker surfaces it discovered +- `.devflare/vite.config.mjs` +- `.wrangler/deploy/config.json` +- `env.d.ts` + +> **Warning โ€” Passthrough is an explicit escape hatch** +> +> It wins on top-level key conflicts, so use it deliberately instead of turning it into a second config language. + +##### Example โ€” Use passthrough for unsupported Wrangler keys + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'advanced-worker', + files: { + fetch: 'src/fetch.ts' + }, + wrangler: { + passthrough: { + placement: { + mode: 'smart' + } + } + } +}) +``` + +--- + +### Scan one full `devflare.config.ts` example with the main current config lanes in one place + +> See one canonical `devflare.config.ts` that touches the main current config lanes in a single file, with hover coverage on every property shown in the example. + +| Field | Value | +| --- | --- | +| Route | [`/docs/full-config`](/docs/full-config) | +| Group | Devflare | +| Navigation title | Full config | +| Eyebrow | Configuration | + +This page is the quick โ€œshow me the whole shapeโ€ version of Devflare config. It is intentionally full enough to scan the current top-level lanes in one file without turning into a maximal dump of every possible nested variant. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Seeing the whole current config shape before you zoom into one subsection | +| Reading pattern | Scan the example first, then hover properties, then open the specialist page you actually need | +| Important boundary | This example is canonical, but not every binding family variant is shown inline | + +#### Use one canonical example when you want the whole shape in view + +When you already know Devflare is split into config, runtime, testing, and framework lanes, the next practical question is often just: what does a full current config actually look like? + +That is what this page is for. The example below touches the major current top-level config lanes in one place, while still staying readable enough for code review and copy-with-intent adaptation. + +> **Note โ€” Full does not mean maximal** +> +> Every property shown above is real and current, but some binding families accept richer object variants than this page needs to show. Use this page as the canonical shape, then open the dedicated binding or configuration page when you need a deeper variant. + +##### Example โ€” One full config example you can scan top to bottom + +Hover any property in the config to see what that lane means. The example is intentionally broad, but the dedicated pages still own the deeper caveats and richer nested variants. + +```ts +import { defineConfig, env } from 'devflare/config' + +export default defineConfig({ + name: 'docs-platform', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2026-03-17', + compatibilityFlags: ['urlpattern_polyfill'], + previews: { + includeCrons: false + }, + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts', + entrypoints: 'src/ep/**/*.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + workflows: 'src/workflows/**/*.ts', + transport: 'src/transport.ts' + }, + bindings: { + kv: { + CACHE: 'docs-cache' + }, + d1: { + PRIMARY_DB: 'docs-db' + }, + r2: { + UPLOADS: 'docs-uploads' + }, + durableObjects: { + CHAT_ROOMS: 'ChatRoom' + }, + queues: { + producers: { + EMAILS: 'docs-emails' + }, + consumers: [ + { + queue: 'docs-emails', + deadLetterQueue: 'docs-emails-dlq', + maxBatchSize: 50, + maxBatchTimeout: 10, + maxRetries: 5, + maxConcurrency: 2, + retryDelay: 30 + } + ] + }, + services: { + AUTH: { + service: 'auth-worker' + } + }, + ai: { + binding: 'AI' + }, + vectorize: { + SEARCH_INDEX: { + indexName: 'docs-search' + } + }, + hyperdrive: { + APP_DB: 'docs-primary-db' + }, + browser: { + BROWSER: 'browser' + }, + analyticsEngine: { + REQUESTS: { + dataset: 'docs_requests' + } + }, + sendEmail: { + MAILER: { + destinationAddress: 'team@example.com' + } + } + }, + triggers: { + crons: ['0 */6 * * *'] + }, + vars: { + APP_ENV: 'development', + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE + }, + retries: env.RETRIES.parse(Number) + }, + secrets: { + API_TOKEN: { + required: true + } + }, + routes: [ + { + pattern: 'docs.example.com', + custom_domain: true + } + ], + wsRoutes: [ + { + pattern: '/ws/:id', + doNamespace: 'CHAT_ROOMS', + idParam: 'id', + forwardPath: '/websocket' + } + ], + assets: { + directory: 'static', + binding: 'ASSETS' + }, + limits: { + cpu_ms: 50 + }, + observability: { + enabled: true, + head_sampling_rate: 1 + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ChatRoom'] + } + ], + rolldown: { + target: 'es2022', + minify: true, + sourcemap: true, + options: {} + }, + vite: { + plugins: [] + }, + env: { + preview: { + vars: { + APP_ENV: 'preview' + }, + previews: { + includeCrons: false + }, + observability: { + enabled: true, + head_sampling_rate: 1 + } + }, + production: { + vars: { + APP_ENV: 'production' + } + } + }, + wrangler: { + passthrough: { + logpush: true + } + } +}) +``` + +#### Know what each top-level lane is doing + +##### Reference table + +| Lane | What it owns | Open next when you need more | +| --- | --- | --- | +| `name`, `accountId`, `compatibility*` | Worker identity and runtime posture. | `config-basics` and `runtime-deploy-settings` | +| `previews`, `files`, `bindings`, `triggers` | The authored Worker shape: surfaces, bindings, and scheduled intent. | `project-shape`, `worker-surfaces`, and `config-previews` | +| `vars`, `secrets`, `env` | Runtime strings, secret declarations, and environment overlays. | `config-environments` | +| `routes`, `wsRoutes`, `assets` | Deployment routing, dev WebSocket proxy rules, and static asset delivery. | `runtime-deploy-settings` | +| `limits`, `observability`, `migrations` | Operational posture and release-time controls. | `runtime-deploy-settings` | +| `rolldown`, `vite`, `wrangler` | Bundler coordination, host integration, and unsupported Wrangler passthrough. | `config-basics`, `vite-standalone`, and `svelte-with-rolldown` | + +#### Open the specialist page once the full picture is clear + +##### Highlights + +- **Need the authoring rules?** โ€” Open config basics when the question is what should live in authored config versus generated output or deploy-time resolution. ([link](/docs/config-basics)) +- **Need the project shape story?** โ€” Open project shape when the main question is how many Worker surfaces or discovery lanes the package should actually own. ([link](/docs/project-shape)) +- **Need preview or environment overlays?** โ€” Use the environments and previews pages when the full config turns into a question about per-lane overrides or preview-scoped resources. ([link](/docs/config-environments)) +- **Need runtime and deploy posture?** โ€” Open runtime and deploy settings when the question is routes, assets, WebSocket proxy rules, observability, limits, or migrations. ([link](/docs/runtime-deploy-settings)) + +--- + +### Configure the project shape around explicit file surfaces before the package gets noisy + +> Start with one fetch file, then add routes, background handlers, Durable Objects, assets, and transport rules only when the project genuinely needs them. + +| Field | Value | +| --- | --- | +| Route | [`/docs/project-shape`](/docs/project-shape) | +| Group | Devflare | +| Navigation title | Project shape | +| Eyebrow | Configuration | + +The config keys that shape a Devflare project are mostly about which files or globs Devflare should treat as real runtime surfaces. Keep that shape small at first, then expand it deliberately instead of letting autodiscovery and generated output become the accidental architecture. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Teams deciding how many runtime surfaces one package actually needs | +| Primary shape keys | `files.*`, `assets`, `routes`, and `wsRoutes` | +| Safest habit | Add one surface only when the current project shape truly asks for it | + +#### Start with the smallest honest project shape + +Devflare does not ask you to configure every possible Worker surface up front. The clean starting point is one fetch entry, then a route tree, a queue consumer, Durable Objects, or other surfaces only when the package actually needs them. + +That keeps the authored config readable in code review and stops the project structure from silently inheriting complexity just because a default glob or generated file happened to exist. + +##### Steps + +1. Start with `files.fetch` for the main HTTP Worker surface. +2. Add `files.routes` when multiple URLs deserve their own modules. +3. Add background surfaces such as `queue`, `scheduled`, or `email` only when the package truly owns those events. +4. Add `durableObjects`, `entrypoints`, `workflows`, or `transport` only when the runtime contract calls for them. +5. Keep static assets, deployment routes, and WebSocket proxy rules in their own config lanes instead of smuggling them into file conventions. + +> **Tip โ€” Project shape is part of architecture** +> +> If the config says one package owns five runtime surfaces, reviewers should be able to see why. Devflare works best when that shape is explicit instead of accidental. + +#### Know which keys actually shape the project + +##### Reference table + +| Config lane | Use it when | Project effect | +| --- | --- | --- | +| `files.fetch` | One main Worker surface should own request-wide behavior. | Points Devflare at the fetch entry you author directly. | +| `files.routes` | The project needs route modules or a mounted route prefix. | Lets a route tree sit beside or replace the main fetch file. | +| `files.queue`, `files.scheduled`, `files.email` | The package consumes background or platform-triggered events. | Adds separate handler files for those runtime surfaces. | +| `files.durableObjects`, `files.entrypoints`, `files.workflows` | The project needs stateful classes, named entrypoints, or workflow definitions. | Turns globs into additional Worker-owned code surfaces Devflare can discover and bundle. | +| `files.transport` | Custom value transport is needed for richer Worker or Durable Object contracts. | Lets you point at one explicit transport file, or disable autodiscovery with `null`. | +| `assets`, `routes`, `wsRoutes` | Static files, deployment routing, or dev WebSocket proxy behavior need their own config. | Keeps non-handler project concerns out of the file-surface lane. | + +##### Example โ€” One config can stay readable even when the package grows a few real surfaces + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + }, + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + durableObjects: 'src/do/**/*.ts', + transport: null + }, + assets: { + directory: 'public' + } +}) +``` + +#### Use autodiscovery deliberately, and disable it explicitly when you mean it + +##### Key points + +- Omit `files.routes` when the default `src/routes` location is already the right fit. +- Use an explicit `files.routes` object when the route root or prefix should be obvious in config review. +- Set `files.routes: false` when the package should not use file-route discovery at all. +- Set `files.transport: null` when you want transport autodiscovery disabled instead of guessed. +- Use explicit file or glob paths when the project layout is non-standard enough that the default convention would hide intent. + +> **Warning โ€” Conventions are only helpful when they still describe the project accurately** +> +> As soon as a default convention stops being obvious, move back to explicit config. That is usually the more maintainable choice. + +#### Open the deeper page for the shape you just introduced + +##### Highlights + +- **Need the broader package setup map?** โ€” Open project architecture when the question is the full package layout โ€” authored config, runtime files, generated output, hosted app files, or monorepo boundaries. ([link](/docs/project-architecture)) +- **Need route modules?** โ€” Open the HTTP routing page when `files.routes` becomes part of the project shape. ([link](/docs/http-routing)) +- **Need transport?** โ€” Read the transport page when a custom transport file becomes part of the contract between worker code and stateful surfaces. ([link](/docs/transport-file)) +- **Need generated env types?** โ€” Open the generated types page when bindings, Durable Objects, or named entrypoints become part of the package contract. ([link](/docs/generated-types)) +- **Need a host shell?** โ€” Open the framework pages only when the package truly becomes a Vite or SvelteKit app instead of a worker-first package. ([link](/docs/vite-standalone)) + +--- + +### Treat fetch, queue, scheduled, and email handlers as separate Worker surfaces with their own files + +> Devflare can compose or wrap several Worker surfaces into one generated entrypoint, but the authored source of truth should stay in explicit files such as `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, and `src/email.ts`. + +| Field | Value | +| --- | --- | +| Route | [`/docs/worker-surfaces`](/docs/worker-surfaces) | +| Group | Devflare | +| Navigation title | Worker surfaces | +| Eyebrow | Configuration | + +A single Devflare package can own more than one Cloudflare event surface. Keep each surface in its own file when the package genuinely owns that event type, wire schedules through `triggers.crons`, and let the generated composed entrypoint stay generated instead of hand-maintained. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Packages that own both HTTP and background event surfaces | +| Default files | `src/fetch.ts`, `src/queue.ts`, `src/scheduled.ts`, `src/email.ts` | +| Generated output | `.devflare/worker-entrypoints/main.ts` when Devflare needs to wrap or compose the worker surfaces it discovered | +| Test helpers | `cf.worker`, `cf.queue`, `cf.scheduled`, and `cf.email` | + +#### Keep each event surface in its own lane + +Devflare does not flatten every Cloudflare event into one mystery handler. When one package owns HTTP, queue consumption, cron jobs, or inbound email, the cleanest shape is usually one file per surface so ownership stays obvious in code review. + +That separation is especially useful once the package has both request/response code and background work. The HTTP story stays in fetch or routes, while queue, scheduled, and email code can evolve without disappearing into one huge entry file. + +##### Reference table + +| Surface | Conventional file | Use it when | Helper | +| --- | --- | --- | --- | +| Fetch | `src/fetch.ts` or `src/routes/**` | HTTP requests belong to one main handler or route tree. | `cf.worker.get()` / `cf.worker.fetch()` | +| Queue consumer | `src/queue.ts` | The package owns deferred, batched, or retryable queue work. | `cf.queue.trigger()` | +| Scheduled handler | `src/scheduled.ts` plus `triggers.crons` | Time-based jobs should run from config-owned schedules. | `cf.scheduled.trigger()` | +| Email handler | `src/email.ts` | The Worker handles inbound email or local email-handler flows. | `cf.email.send()` | + +#### Put scheduled intent in config instead of scripts or comments + +A scheduled handler is only half the story. The code lives in `src/scheduled.ts`, but the timing contract belongs in `triggers.crons` so the package declares when the job should run instead of relying on external shell memory. + +Preview behavior belongs in config too. `previews.includeCrons` defaults to `false`, so branch-scoped preview deploys drop cron triggers unless you opt them back in deliberately. + +> **Warning โ€” Preview environments should not inherit cron behavior by accident** +> +> If previews should run scheduled jobs, say so explicitly. Otherwise keep preview validation focused on the surfaces reviewers actually expect to exercise. + +##### Example โ€” A package that owns several Worker surfaces explicitly + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'jobs-worker', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + routes: false + }, + triggers: { + crons: ['0 */6 * * *'] + }, + previews: { + includeCrons: false + } +}) +``` + +#### Disable unused conventions explicitly and let Devflare compose the rest + +Generated composition is not only a build detail. The local dev server also uses the same surface model to decide what to watch, so the directories around configured or conventional fetch, queue, scheduled, email, route, and transport files all become reload roots. + +That split is intentional: config-file edits take the config reload path, while worker-source changes under those watched roots take the worker reload path. You do not need a second watch system just because the package grew another surface. + +##### Key points + +- Set `files.queue: false`, `files.scheduled: false`, or `files.email: false` when one of the default conventions should stay off. +- Set `files.routes: false` when the package should stay fetch-only instead of discovering a route tree. +- When a fetch entry, route tree, or background surface set needs wrapper glue, Devflare can generate a composed entrypoint under `.devflare/worker-entrypoints/main.ts` to fan them into the Worker runtime correctly. +- If `wrangler.passthrough.main` is set, or the fetch worker already lives at `assets.directory/_worker.js`, Devflare skips that generated main entry and uses the explicit worker instead. +- Generated entrypoints are supposed to churn as the surface set changes. Keep the authored files and config authoritative, and let the glue stay disposable. +- Treat that generated entrypoint as output. The authored source of truth remains the explicit files and config that selected them. + +> **Note โ€” Dev reload follows the same surface roots** +> +> Worker-source changes under the watched fetch, queue, scheduled, email, route, or transport roots trigger the worker reload path, while edits to the resolved `devflare.config.*` trigger the config reload path instead. + +> **Note โ€” Tail is still a special case** +> +> Devflare can exercise tail behavior in the test harness when `src/tail.ts` exists, but there is not yet a public `files.tail` config key. Keep the main project-shape story centered on the documented event surfaces, and open the `createTestContext()` page when the question is tail testing. + +#### Some nearby `files.*` keys are discovery globs, not event handlers + +Not every `files.*` key means โ€œCloudflare will call this file as an event surface.โ€ Some keys tell Devflare where to discover related program structure such as Durable Object classes, named entrypoints, workflow definitions, or transport hooks. + +That distinction matters because it keeps code review honest. Event surfaces answer โ€œwhat can invoke this package?โ€, while discovery globs answer โ€œwhat else should Devflare scan and bundle for the runtime contract?โ€ + +##### Highlights + +- **Need transport behavior?** โ€” Open the transport page when a discovered transport file becomes part of the package contract. ([link](/docs/transport-file)) +- **Need the generated type contract?** โ€” Open the generated types page when `files.entrypoints`, `ref()`, or discovered Durable Objects need to show up correctly in `env.d.ts`. ([link](/docs/generated-types)) +- **Need the broader config map?** โ€” The runtime and deploy settings page covers the non-surface knobs such as account context, compatibility posture, routes, assets, limits, and migrations. ([link](/docs/runtime-deploy-settings)) + +##### Reference table + +| Config key | What it points at | Why it is different | +| --- | --- | --- | +| `files.durableObjects` | Durable Object class files or globs | These classes are discovered and wrapped; they are not a standalone top-level event surface like fetch or queue. | +| `files.entrypoints` | Named entrypoint files or globs | These support typed cross-worker references and discovery, not a separate Cloudflare event hook. | +| `files.workflows` | Workflow definition files or globs | These are additional discovered modules, not a direct replacement for fetch, queue, scheduled, or email handlers. | +| `files.transport` | One custom transport file | This is a serialization hook for bridge-backed calls, not an event handler that Cloudflare dispatches directly. | + +--- + +### Use `devflare types` to keep `env.d.ts` and `Entrypoints` aligned with the project you actually authored + +> `devflare types` turns config, discovered Durable Objects, named entrypoints, and cross-worker references into one generated TypeScript contract instead of a pile of hand-maintained env guesswork. + +| Field | Value | +| --- | --- | +| Route | [`/docs/generated-types`](/docs/generated-types) | +| Group | Devflare | +| Navigation title | Generated types | +| Eyebrow | Configuration | + +The generated file is more than editor garnish. It is the typed mirror of your Devflare config and discovery rules: bindings land on global `DevflareEnv`, named entrypoints become an exported `Entrypoints` union, and referenced workers can produce typed service interfaces when Devflare can follow them accurately. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Packages that use bindings, Durable Objects, service bindings, or named worker entrypoints | +| Main command | `bunx --bun devflare types` | +| Default output | `env.d.ts` relative to the directory you run the command from unless you override it | +| Best pairing | `defineConfig()` on the referenced worker config | + +#### Treat the generated file as the typed contract, not as handwritten glue + +`devflare types` reads the resolved config, discovers supporting source files, and writes one generated file that says what the package runtime actually exposes. This is more reliable than hand-maintained `env` declarations because the source of truth stays in config and file discovery, not in a second hand-maintained type file. + +The result is usually a global `DevflareEnv` interface plus an exported `Entrypoints` union. That combination is what keeps bindings, cross-worker service calls, and named entrypoints typed without making you manually mirror every config change. + +> **Warning โ€” Generated means generated** +> +> Do not hand-edit `env.d.ts` and expect the next run to preserve it. Change config or source files, then rerun `devflare types`. + +##### Example โ€” A generated file should read like output, not a second config file + +###### File โ€” env.d.ts + +```ts +// Generated by devflare - DO NOT EDIT +// Run devflare types to regenerate + +import type { MathServiceInterface } from '../src/math-service.types' +import type { AdminEntrypointInterface } from '../src/math-service.types' + +declare global { + interface DevflareEnv { + MATH_SERVICE: MathServiceInterface + ADMIN: AdminEntrypointInterface + } +} + +/** + * Named entrypoints discovered from ep.*.ts files. + * Use with defineConfig() for type-safe cross-worker references. + */ +export type Entrypoints = 'AdminEntrypoint' +``` + +##### Example โ€” The command loop stays intentionally small + +```bash +bunx --bun devflare types +bunx --bun devflare types --output env.generated.d.ts +``` + +#### Know what the command is actually discovering + +##### Key points + +- If no named entrypoints are discovered yet, `Entrypoints` stays `string` โ€” the fallback is intentional. +- `devflare types` does not take an `--env` flag today, so the generated contract reflects the resolved base config rather than a named environment overlay. +- If you choose a nested `--output` path, create the parent directory first; the command writes the file but does not scaffold missing folders for you. +- Discovery follows the configured file patterns first, then falls back to the default Durable Object and entrypoint globs. +- The generated types are only as good as the authored config and file naming conventions they can see. + +##### Reference table + +| Input Devflare reads | Where it comes from | Typed result | +| --- | --- | --- | +| `bindings`, `vars`, and `secrets` | The resolved top-level `devflare.config.*` from the current working directory or explicit `--config` path. | Members on global `DevflareEnv`. | +| Local Durable Object classes | `files.durableObjects` or the default `**/do.*.{ts,js}` discovery pattern. | `DurableObjectNamespace<...>` when the class can be located accurately. | +| Named worker entrypoints | `files.entrypoints` or the default `**/ep.*.{ts,js}` discovery pattern plus exported classes extending `WorkerEntrypoint`. | An exported `Entrypoints` union for `defineConfig()`. | +| `ref()` references | Imported Devflare configs in other packages or subfolders. | Typed service bindings and cross-worker Durable Object namespaces when Devflare can resolve them. | +| Unknown or unresolvable service surface | A target worker or entrypoint that cannot be turned into a stable interface. | `Fetcher` fallback instead of fake precision. | + +> **Note โ€” Typed fallback is still honest typing** +> +> Getting `Fetcher` for a service binding is not a failure of the generator so much as Devflare refusing to invent a stronger interface than it can justify from the available source. + +#### Type the worker that owns the entrypoints, then let `ref()` carry that knowledge + +The `Entrypoints` union matters most on the worker being referenced. Import that generated type into the worker's own config and pass it to `defineConfig()`, then callers that use `ref(() => import(...))` can ask for named entrypoints without turning those names into loose string conventions. + +That keeps the typing relationship honest: the worker that owns `ep.*.ts` files declares which entrypoints exist, and the worker that consumes them gets autocomplete and checking through `ref().worker('...')` later. + +##### Key points + +- Put `defineConfig()` on the referenced worker config, not on every caller in the repo by reflex. +- Keep the named entrypoint files boring and explicit: `ep.*.ts` plus classes extending `WorkerEntrypoint`. +- Rerun `devflare types` in the worker that owns those entrypoints whenever you rename a class or add another one. + +> **Warning โ€” Types are not a substitute for critical deploy validation** +> +> Named service entrypoints are modeled at the Devflare layer, but if a particular service path is operationally critical, still inspect the compiled output with `devflare build` or `devflare config print --format wrangler` before trusting muscle memory. + +##### Example โ€” One worker declares the entrypoints, another consumes them through `ref()` + +###### File โ€” math-service/ep.admin.ts + +```ts +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class AdminEntrypoint extends WorkerEntrypoint { + async resetStats(): Promise<{ success: boolean }> { + return { success: true } + } +} +``` + +###### File โ€” math-service/devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' +import type { Entrypoints } from './env' + +export default defineConfig({ + name: 'math-worker', + files: { + fetch: 'worker.ts' + } +}) +``` + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig, ref } from 'devflare/config' + +const mathWorker = ref(() => import('./math-service/devflare.config')) + +export default defineConfig({ + name: 'case5-gateway', + bindings: { + services: { + MATH_SERVICE: mathWorker.worker, + ADMIN: mathWorker.worker('AdminEntrypoint') + } + } +}) +``` + +#### Keep the generated contract boring and rerunnable + +##### Highlights + +- **Need the multi-worker architecture story?** โ€” Open the multi-worker page when the question is whether another worker boundary is warranted before you worry about typing that boundary. ([link](/docs/multi-workers)) +- **Need the surface-discovery map?** โ€” The worker-surfaces page explains which authored files and discovery globs become part of the worker contract in the first place. ([link](/docs/worker-surfaces)) +- **Need the broader command map?** โ€” The CLI page keeps `types`, `build`, `deploy`, `doctor`, and config-inspection commands in one everyday workflow map. ([link](/docs/devflare-cli)) + +##### Steps + +1. Run `devflare types` after adding or renaming bindings, Durable Objects, service references, or named entrypoints. +2. Keep the default cwd-relative `env.d.ts` location unless a custom `--output` path truly buys something more than folder aesthetics. +3. Import `Entrypoints` from the generated file only where the owning worker config needs it. +4. Inspect compiled output when a cross-worker or entrypoint boundary matters operationally, not just ergonomically in the editor. + +--- + +### Use `config.env` overlays to change only what differs between local, preview, and production + +> Keep one base config, layer environment-specific overrides with `config.env`, and let Devflare resolve preview or production details only in the commands that actually need them. + +| Field | Value | +| --- | --- | +| Route | [`/docs/config-environments`](/docs/config-environments) | +| Group | Devflare | +| Navigation title | Environments | +| Eyebrow | Configuration | + +Devflare environments are an overlay system, not a second copy of the whole config file. The base config should hold the stable project story, and `config.env` should only override the parts that genuinely differ by environment. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Projects that need different bindings or runtime behavior in preview and production | +| Merge model | Base config first, then `config.env[name]`, then preview materialization when relevant | +| Main habit | Repeat only the keys that actually differ by environment | + +#### Keep one base config and let the overlay change only the deltas + +The main config should describe the stable project: the worker name, the usual file surfaces, and the bindings or defaults that exist regardless of environment. `config.env` is where you change only the parts that diverge for preview, production, or another named lane. + +The overlay model feels more predictable than copying whole config files around. The shared story stays in one place, while the environment-specific differences stay small enough to review accurately. + +> **Tip โ€” A smaller overlay is usually a better overlay** +> +> If an environment block starts to repeat most of the base config, that is usually a sign the base config should be refactored instead of duplicated. + +##### Example โ€” Use `config.env` for targeted overrides instead of a second full config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-api', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + CACHE: 'notes-cache' + } + }, + vars: { + APP_ENV: 'local' + }, + env: { + preview: { + bindings: { + kv: { + CACHE: 'notes-preview-cache' + } + }, + vars: { + APP_ENV: 'preview' + }, + previews: { + includeCrons: false + } + }, + production: { + vars: { + APP_ENV: 'production' + }, + observability: { + enabled: true, + head_sampling_rate: 1 + } + } + } +}) +``` + +#### Know what environment overlays are actually allowed to change + +This is why `config.env` is more than a raw Wrangler mirror. It can change the Devflare-owned parts of the project too, as long as those differences are still part of the same package story. + +##### Reference table + +| Override lane | Typical reason to change it | +| --- | --- | +| `name`, compatibility settings | The environment truly needs a different runtime identity or compatibility posture. | +| `files`, `bindings`, `triggers` | Preview or production uses different surfaces, resources, or schedules. | +| `vars`, `secrets` | Runtime strings or secret-binding declarations differ by environment. | +| `routes`, `assets`, `limits`, `observability` | Deployment routing, static assets, CPU limits, or observability should differ by lane. | +| `rolldown`, `vite`, `wrangler` | The build host or the passthrough escape hatch needs environment-specific behavior. | + +#### Environment overrides: arrays replace, objects deep-merge, primitives replace + +Overlays compose onto the base config with three rules: object-shaped values are deep-merged key by key, primitive values (strings, numbers, booleans) are replaced wholesale, and array-shaped values are replaced wholesale (they do not append). Reading an environment block as an override of the base โ€” not as an addition to it โ€” keeps these rules predictable. + +The replace-arrays rule is the one most likely to surprise someone arriving from a config system that appended arrays. If a base config sets `routes: [โ€ฆ]` and the overlay sets `routes: [โ€ฆ]`, the overlayโ€™s array becomes the resolved value; the base array is not concatenated. The same applies to `migrations` and to nested arrays like `triggers.crons`. + +##### Reference table + +| Field shape | Merge rule | Example | +| --- | --- | --- | +| `routes` (array) | Replace | Base `routes: [{ pattern: "app.example.com/*", zone_name: "example.com" }]` + overlay `routes: [{ pattern: "preview.example.com/*", zone_name: "example.com" }]` resolves to **only** the preview entry. | +| `migrations` (array) | Replace | Base `migrations: [{ tag: "v1", new_classes: ["Room"] }]` + overlay `migrations: [{ tag: "v2", new_classes: ["Room", "User"] }]` resolves to **only** the v2 entry. To preserve history, restate the prior migrations in the overlay. | +| `triggers.crons` (array under nested object) | Replace at the array level (the parent `triggers` object is still deep-merged) | Base `triggers: { crons: ["*/5 * * * *"] }` + overlay `triggers: { crons: ["0 * * * *"] }` resolves to `triggers.crons = ["0 * * * *"]`. Other keys on `triggers` deep-merge as usual. | +| `bindings` (object) | Deep-merge | Adding `bindings.kv.NEW_NS` in an overlay extends the base `bindings.kv` map; existing namespaces survive unless the overlay names the same key. | +| `name`, `compatibility_date` (primitive) | Replace | The overlay value wins when present; otherwise the base value stays. | + +> **Warning โ€” Arrays replace, they do not append** +> +> If you only want to add one extra route, one extra cron, or one extra migration to the base, the overlay must restate the base entries alongside the new one. An overlay that lists only the new entry will silently drop the base entries from the resolved config. + +#### Choose the environment where it matters, and let explicit deploy targets do the rest + +##### Steps + +1. Use commands like `devflare config --env ` or `devflare build --env ` when you want to inspect or compile one named environment intentionally. +2. Let explicit preview deploys target the preview environment instead of also layering on an unrelated `--env` decision. +3. Let explicit production deploys stay pinned to production so the deployment target is never ambiguous. +4. Keep preview-only resource naming and preview lifecycle behavior inside the preview lane instead of leaking it into the base config. + +> **Note โ€” Environment choice and deploy target are related, but not identical** +> +> `--env` chooses a config overlay for commands that resolve config environments. Explicit preview and production deploy flags choose the deployment destination itself. + +#### Keep `.env`, `vars`, and `secrets` in separate jobs + +##### Key points + +- Use `.env` and `.env.dev` for config-time inputs. Devflare reads those files itself from the config directory upward, with closer files winning and `.env` overriding `.env.dev` in the same directory. +- Use `vars` for values that should compile into Worker-facing output, including nested typed values produced by `env.NAME` descriptors. +- Use `secrets` to declare runtime secret binding names, not to store those secret values in config. Today that is mostly schema and type metadata: the schema accepts `{ required: false }`, but generated env typing still treats declared secrets as present and Devflare does not currently turn that flag into a separate deploy-time guarantee. +- Use `.env.example` to document config-time inputs for the team instead of leaving those values to memory or chat scrollback. + +> **Warning โ€” Do not let every string become an environment variable by reflex** +> +> Stable infrastructure names and intentional runtime strings usually belong in authored config. Save secrets for the values that are actually secret. + +--- + +### Resolve `.env` values through typed config vars instead of scattering process env reads + +> Use `env.NAME` descriptors inside `defineConfig({ vars })`, parse or default them in config, and read the resulting typed values at runtime with `import { vars } from "devflare"`. + +| Field | Value | +| --- | --- | +| Route | [`/docs/typed-env-vars`](/docs/typed-env-vars) | +| Group | Devflare | +| Navigation title | Typed env vars | +| Eyebrow | Configuration | + +Devflare vars can now be a typed bridge from local `.env` files into Worker runtime code. The config owns which variables are required, optional, parsed, or dev-only, while application code reads the resolved shape through the `vars` runtime helper. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config import | `import { defineConfig, env } from 'devflare/config'` | +| Runtime import | `import { vars } from 'devflare'` | +| File order | Parents first, then closer directories; `.env.dev` first, `.env` last | +| Missing build vars | Build fails with a nested missing-variable report | + +#### Declare the runtime shape in config + +The `env` export from `devflare/config` does not read the variable immediately. It creates a descriptor that Devflare resolves when it starts dev, builds artifacts, or prints a phase-resolved config. + +That keeps config import cheap and lets Devflare report every missing variable at once, using the nested path from `vars` instead of a generic process-env crash. + +##### Example โ€” Nested vars with required, optional, parsed, defaulted, and dev-only values + +```ts +import { defineConfig, env } from 'devflare/config' + +export default defineConfig({ + name: 'voices-api', + vars: { + secret: env.SECRET, + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE + }, + retries: env.RETRIES.parse(Number), + optionalLabel: env.OPTIONAL_LABEL.optional(), + mode: env.APP_MODE.default('local'), + mockTenantId: env.MOCK_TENANT_ID.dev(123) + } +}) +``` + +#### Read resolved values through the runtime `vars` helper + +At runtime, Devflare exposes the resolved values on the Worker environment and through the `vars` helper. The helper is typed from `devflare types`, so parser return values and nested objects stay visible to TypeScript. + +Unparsed environment descriptors resolve to strings. Parsed descriptors use the parser return type, defaults contribute their value type, and optional descriptors become optional properties. + +##### Example โ€” Runtime code can use the nested shape directly + +###### File โ€” src/fetch.ts + +```ts +import { vars } from 'devflare' + +export default { + async fetch() { + return Response.json({ + database: vars.mongo.database, + retries: vars.retries + }) + } +} +``` + +#### Let Devflare parse `.env` files itself + +Devflare reads `.env.dev` and `.env` from the config directory and every parent directory. Parent files load first, then closer files override them. Within one directory, `.env.dev` loads first and `.env` wins last. + +The parser does not expand `$OTHER_VARIABLE` references. Values such as passwords, MongoDB connection strings, and shell-looking fragments are read as written instead of being interpreted by Bun. + +> **Note โ€” Process env still wins over files** +> +> CI-provided environment variables and explicit shell exports override `.env` file values. Dotenv files fill in missing process variables; they do not stomp values the process already had. + +##### Example โ€” The later `.env` value overrides the earlier `.env.dev` value + +###### File โ€” .env + +```dotenv +# .env.dev +SECRET=local-secret +MONGOURI=mongodb://127.0.0.1:27017 +MONGODATABASE=voices_dev +RETRIES=1 + +# .env +MONGODATABASE=voices +``` + +#### Missing required values fail build and pause dev + +Required is the default because a config variable usually means the Worker cannot run honestly without that value. Build and config-inspection commands fail with a grouped report that points at the nested `vars` path and the missing environment variable name. + +Dev mode is gentler. It prints the same report, waits for `.env` or `.env.dev` to change, and then retries startup. That makes the local loop fixable without restarting the command. + +##### Example โ€” Missing variables are grouped by the config path that required them + +###### File โ€” missing-env-vars.txt + +```text +These environment variables are missing: + + secret: SECRET + mongo: + uri: MONGOURI +``` + +#### Use helpers to make intent explicit + +##### Reference table + +| Helper | Meaning | Example | +| --- | --- | --- | +| `env.NAME` | Required string value. | `env.SECRET` | +| `.optional()` | Missing value is allowed and omitted. | `env.OPTIONAL_LABEL.optional()` | +| `.parse(fn)` / `.parser(fn)` | Transform the string from env files into a typed runtime value. | `env.RETRIES.parse(Number)` | +| `.default(value)` | Use a fallback in every mode when the env value is missing. | `env.APP_MODE.default('local')` | +| `.dev(value)` | Use a fallback only in dev when the env value is missing. | `env.MOCK_TENANT_ID.dev(123)` | + +> **Warning โ€” Dev-only defaults are still required in build** +> +> `.dev(value)` is intentionally local-only. If the same variable may be missing in build too, use `.default(value)` or `.optional()` instead. + +--- + +### Author preview-scoped bindings so preview deploys can own disposable infrastructure + +> Use `preview.scope()` for bindings that should belong to one preview scope. Devflare materializes names like `notes-db-next`, provisions or reuses the preview-only resources it can manage, and lets you clean them up by the same scope later without touching production resources. + +| Field | Value | +| --- | --- | +| Route | [`/docs/config-previews`](/docs/config-previews) | +| Group | Devflare | +| Navigation title | Previews | +| Eyebrow | Configuration | + +Preview config in Devflare is not only โ€œset `env.preview` and hope for the best.โ€ The extra step is marking the bindings that should belong to a preview deployment. Devflare then materializes those names with a preview identifier, keeps production names separate, and on preview deploys can create or reuse the matching account resources for the binding types it manages locally. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Authoring primitive | `preview.scope()` from `devflare/config` | +| Typical result | `notes-cache-kv` โ†’ `notes-cache-kv-next` for a `next` preview scope | +| Main lifecycle command | `bunx --bun devflare previews cleanup --scope --apply` | +| Best for | Previews that need their own disposable state instead of borrowing production infrastructure | + +#### Mark preview-owned bindings in config instead of mutating production names at deploy time + +The point of preview-scoped bindings is not to make names look fancy. It is to keep preview infrastructure isolated from production infrastructure while still authoring one readable config. + +`preview.scope()` returns an opaque marker around the base resource name. Devflare later materializes that marker into a real name for the active preview identifier, which means the authored config can stay stable while preview deploys resolve to preview-owned databases, buckets, queues, and other resources. + +> **Tip โ€” This is safer than repointing previews at production state** +> +> When the preview owns a distinct database or queue name, it can be created quickly, reviewed in isolation, and deleted cleanly later. That is much safer than hoping reviewers never touch a production binding in a preview session. + +##### Example โ€” Author preview-owned bindings once, then let the scope decide the real names + +```ts +import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + name: 'notes-api', + bindings: { + kv: { + CACHE: pv('notes-cache-kv') + }, + d1: { + PRIMARY_DB: pv('notes-db') + }, + r2: { + UPLOADS: pv('notes-uploads-bucket') + }, + queues: { + producers: { + EMAILS: pv('notes-emails-queue') + }, + consumers: [ + { + queue: pv('notes-emails-queue'), + deadLetterQueue: pv('notes-emails-dlq') + } + ] + } + }, + env: { + preview: { + vars: { + APP_ENV: 'preview' + } + }, + production: { + bindings: { + kv: { + CACHE: 'notes-cache-kv-production' + }, + d1: { + PRIMARY_DB: 'notes-db-production' + } + }, + vars: { + APP_ENV: 'production' + } + } + } +}) +``` + +#### The preview identifier is materialized into the binding target name + +In normal local work and non-preview environments, a preview-scoped marker resolves back to the base name. In preview resolution, Devflare inserts the chosen preview identifier using the configured separator, which defaults to `-`. + +The identifier order is deliberate: an explicit identifier wins first, then `DEVFLARE_PREVIEW_IDENTIFIER`, then PR or branch-derived env values, and only then the synthetic `preview` fallback for generic preview environments. + +##### Key points + +- The binding name in `env` stays the same; it is the backing resource target that changes by preview scope. +- Production overrides can still point at explicit production resources when production naming should be fully separate from preview naming. +- This page is about resource naming and binding targets; preview worker topology is a neighboring decision covered by the preview strategy docs. + +##### Reference table + +| Authored binding target | When it resolves | Resolved name | What that means | +| --- | --- | --- | --- | +| `pv('notes-cache-kv')` | Local work or non-preview resolution | `notes-cache-kv` | The base config stays readable and does not invent preview names unless a preview identifier is actually in play. | +| `pv('notes-cache-kv')` | Plain `--preview` or generic preview environment | `notes-cache-kv-preview` | The synthetic `preview` identifier keeps same-worker preview uploads separate from the base resource name. | +| `pv('notes-cache-kv')` | Named preview like `--preview next` or `--scope next` | `notes-cache-kv-next` | A named preview scope gets its own clearly-associated resource names and cleanup target. | +| `pv('notes-cache-kv')` | `DEVFLARE_PREVIEW_BRANCH=Feature/TeSt-Branch` | `notes-cache-kv-feature-test-branch` | Branch-derived identifiers are sanitized into safe resource-name fragments. | +| `preview.scope({ separator: '--' })` | Custom separator plus preview identifier | `notes-cache-kv--next` | You can change the separator when the resource naming convention needs it. | + +#### Some preview-scoped bindings are lifecycle-managed resources, and some are not + +##### Reference table + +| Binding lane | Preview naming story | Lifecycle behavior | +| --- | --- | --- | +| KV, D1, and R2 | Author the resource name with `preview.scope()`. | Preview deploys can create or reuse the scoped resource, and cleanup can delete it later by the same scope. | +| Queues and DLQs | Producer, consumer, and dead-letter queue names can all be scoped. | Preview deploys can provision the queue resources and cleanup can remove them together. | +| Vectorize | Index names can be preview-scoped too. | Devflare can provision the preview index shape from the base index metadata and delete it during cleanup later. | +| Hyperdrive | Names can be materialized for preview scopes. | Devflare does not auto-clone stored credentials, so it warns and can fall back to the base Hyperdrive binding when the preview config does not already exist. | +| Analytics Engine and Browser Rendering | Dataset or binding names can be materialized. | Devflare reports warnings instead of provisioning or deleting account resources because those families do not follow the same managed lifecycle. | +| Service bindings, Durable Objects, and routes on dedicated preview workers | Isolation follows preview worker names and ownership more than account resource naming. | Deleting dedicated preview worker scripts also removes preview-only service bindings, Durable Object bindings, and routes attached only to those workers. | + +> **Warning โ€” Preview-scoped does not automatically mean Devflare can provision everything** +> +> Hyperdrive, Analytics Engine, and Browser Rendering each have their own lifecycle caveats. Devflare says that out loud instead of pretending every binding behaves like KV or D1. + +#### The good preview loop is deploy, inspect, and clean up by the same scope + +Preview-scoped bindings work best when the scope stays explicit from deploy through cleanup. The preview deploy resolves the config to preview-owned names, the binding inspection command shows exactly what that scope points at, and cleanup removes the same preview-only resources later. + +That is what keeps previews fast to create and safe to tear down. The preview owns its own binding targets, so deleting it does not mean touching production databases or buckets just because the app used the same binding names in code. + +##### Highlights + +- **Need the overlay story too?** โ€” Open the environments page when the question is which config lanes differ by preview or production beyond resource naming. ([link](/docs/config-environments)) +- **Need the preview topology decision?** โ€” Open the preview strategy page when the real question is same-worker uploads versus branch-scoped worker families. ([link](/docs/preview-strategies)) +- **Need lifecycle and cleanup commands?** โ€” Open preview operations when the question moves from authoring config to registry inspection or cleanup policy. ([link](/docs/preview-operations)) + +##### Steps + +1. Author preview-owned bindings with `preview.scope()` in the main config. +2. Deploy the preview with an explicit scope such as `--preview next` when the resource names should map to one known preview deployment. +3. Inspect that scope with `devflare previews bindings --scope next` when you want the resolved targets and worker associations spelled out clearly. +4. Clean up the same preview later with `devflare previews cleanup --scope next --apply`. + +##### Example โ€” One scope in, the same scope back out + +```bash +bunx --bun devflare deploy --preview next +bunx --bun devflare previews bindings --scope next +bunx --bun devflare previews cleanup --scope next --apply +``` + +--- + +### Keep runtime posture and deployment shape in authored config instead of scattered deploy conventions + +> Use config for account context, compatibility posture, assets, deployment routes, WebSocket proxy rules, migrations, observability, limits, and preview cron behavior instead of rediscovering those settings in scripts later. + +| Field | Value | +| --- | --- | +| Route | [`/docs/runtime-deploy-settings`](/docs/runtime-deploy-settings) | +| Group | Devflare | +| Navigation title | Runtime & deploy settings | +| Eyebrow | Configuration | + +Devflare exposes several config lanes that are not about file discovery at all. These keys shape runtime identity, Cloudflare compatibility, deployment routing, assets, release behavior, and operational posture, so they belong in authored config where the team can review them accurately. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Projects that need explicit runtime posture and delivery shape beyond the basic file surfaces | +| Forced compatibility flags | `nodejs_compat` and `nodejs_als` | +| Routing split | `files.routes` is app routing, while top-level `routes` is Cloudflare deployment routing | +| Preview cron default | `previews.includeCrons` defaults to `false` | + +#### Set runtime identity and compatibility posture explicitly + +Not every package needs the full advanced runtime section on day one, but once remote bindings, compatibility drift, or account-aware operations matter, these settings should move into config instead of living in loose scripts and remembered defaults. + +The important habit is that runtime posture should be reviewable in source control. If a package relies on a specific compatibility date or a specific Cloudflare account, that fact should be obvious before the deploy step runs. + +##### Reference table + +| Key | Use it when | Important behavior | +| --- | --- | --- | +| `accountId` | Remote bindings, name-based resource lookup, or account-aware commands should target one Cloudflare account explicitly. | Remote AI and Vectorize flows need a clear account, and config-level `accountId` becomes one resolution lane for account-aware operations and config-driven resource resolution. | +| `compatibilityDate` | The package should pin runtime behavior instead of inheriting date drift. | Devflare defaults it to the current date when you omit it, so explicit pinning is the safer choice once the package is real. | +| `compatibilityFlags` | You need extra Workers compatibility flags beyond the default posture. | Devflare always includes `nodejs_compat` and `nodejs_als`, so custom flags should be deliberate additions instead of copy-by-habit repetition. | + +> **Note โ€” Do not restate the forced flags unless you are making a point** +> +> Devflare already includes `nodejs_compat` and `nodejs_als`. Keep `compatibilityFlags` focused on the extra posture your package actually needs. + +#### Choose the Cloudflare endpoint model first + +Cloudflare documents three inbound endpoint models for Workers: Custom Domains, normal Workers routes, and the automatic `workers.dev` route. They are not interchangeable, and Devflare keeps that distinction in the top-level `routes` config instead of inventing a second routing vocabulary. + +Use a Custom Domain when the Worker is the origin for a whole hostname. Use a normal Workers route when a Worker should sit in front of an existing proxied hostname, match a wildcard host, or match a path prefix. Use `workers.dev` for getting started or preview-style reachability, and disable it when production should only be reachable through your own domain. + +##### Reference table + +| Goal | Devflare config shape | Cloudflare behavior | +| --- | --- | --- | +| Worker owns every path on one hostname | `routes: [{ pattern: "app.example.com", custom_domain: true }]` | Custom Domains match the exact hostname; paths and query strings do not participate in the match. | +| Worker intercepts a path or wildcard host in a Cloudflare zone | `routes: [{ pattern: "app.example.com/api/*", zone_name: "example.com" }]` | Normal routes use route patterns, may include `*`, and the most specific matching route wins. | +| Worker remains reachable on the account subdomain | Default generated Wrangler config keeps `workers_dev: true`. | Cloudflare assigns `..workers.dev`; Cloudflare recommends routes or custom domains for production. | + +> **Note โ€” Routes can sit in front of Custom Domains** +> +> Cloudflare treats a Worker on a Custom Domain as an origin. A more specific normal route on the same hostname can run first, then call `fetch(request)` to invoke the Custom Domain Worker behind it. + +> **Warning โ€” Same-zone fetch is different for routes and Custom Domains** +> +> Cloudflare documents that Custom Domains can be invoked by same-zone `fetch()`, while normal routes cannot be the target of same-zone `fetch()` and should use service bindings for Worker-to-Worker calls. + +##### Example โ€” Custom Domain for a Worker-owned hostname + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'app-worker', + routes: [ + { + pattern: 'app.example.com', + custom_domain: true + } + ] +}) +``` + +##### Example โ€” Workers route for path or wildcard matching + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'api-proxy-worker', + routes: [ + { + pattern: 'app.example.com/api/*', + zone_name: 'example.com' + } + ] +}) +``` + +##### Example โ€” Disable workers.dev when only custom endpoints should serve production + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'scratch-worker', + wrangler: { + passthrough: { + workers_dev: false + } + } +}) +``` + +#### Keep deployment shape in config, not in app routing or shell scripts + +Several config keys answer deployment questions rather than application-routing questions. Keeping those lanes separate is what stops app URLs, Cloudflare routes, and dev-only WebSocket proxy behavior from collapsing into one blurry story. + +If the package serves static assets, mounts a custom domain, or proxies Durable Object WebSockets in development, that shape should live in config beside the rest of the deployment contract. + +Custom Domains are host-only: use `custom_domain: true` with a bare hostname such as `docs.example.com`. For wildcard or path matching such as `docs.example.com/*` or `docs.example.com/api/*`, use a normal Workers route with `zone_name` or `zone_id` instead. + +##### Reference table + +| Key | What it controls | Common use | +| --- | --- | --- | +| `assets` | Static asset directory plus optional binding name | Point Devflare at one static directory and keep asset delivery visible in source. | +| `routes` | Cloudflare deployment route patterns | Attach the Worker to host or zone patterns at deploy time. | +| `wsRoutes` | Dev-mode Durable Object WebSocket proxy patterns | Forward development WebSocket paths into Durable Object namespaces explicitly. | + +> **Warning โ€” Top-level `routes` is not the same thing as `files.routes`** +> +> `files.routes` controls your app route tree. Top-level `routes` controls Cloudflare deployment routing. Keep those ideas separate so the package stays reviewable. + +> **Warning โ€” Custom Domains are not wildcard routes** +> +> Cloudflare Custom Domains match the hostname exactly and ignore paths. Do not add `/*` when `custom_domain: true`; a request to any path on that hostname will already invoke the Worker. + +##### Example โ€” One place for runtime posture and deployment-facing settings + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'docs-site', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2026-03-17', + assets: { + directory: 'static', + binding: 'ASSETS' + }, + routes: [ + { pattern: 'docs.example.com', custom_domain: true } + ], + wsRoutes: [ + { + pattern: '/ws/:id', + doNamespace: 'CHAT_ROOMS' + } + ], + limits: { + cpu_ms: 50 + }, + observability: { + enabled: true, + head_sampling_rate: 1 + }, + previews: { + includeCrons: false + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ChatRoom'] + } + ] +}) +``` + +#### Put release and operational controls in source control too + +Once a package has Durable Object history, production traffic expectations, or explicit preview behavior, the runtime contract is no longer just โ€œwhat files exist?โ€ It also includes how that package should be migrated, sampled, and limited at runtime. + +These settings belong in the same config as the Worker surfaces. They are part of the deployable contract, not just garnish around it. + +##### Reference table + +| Key | Why it exists | +| --- | --- | +| `previews.includeCrons` | Choose whether branch-scoped preview deploys keep cron triggers instead of omitting them to avoid shared-schedule conflicts. | +| `limits.cpu_ms` | Declare CPU expectations in config rather than treating them as after-the-fact deploy tuning. | +| `observability.enabled` / `head_sampling_rate` | Keep tracing or sampling posture explicit for the environments that need it. | +| `migrations` | Track Durable Object class lifecycle in the same source-controlled package that owns those classes. | + +> **Warning โ€” Durable Object migrations still deserve explicit release thinking** +> +> Keep migrations authored in config and remember that plain preview uploads do not apply Durable Object migrations. If the preview must exercise real Durable Object lifecycle changes, use the preview strategy that matches that reality. + +#### Open the neighboring page when the setting changes the larger deployment story + +##### Highlights + +- **Need environment overlays?** โ€” Use the environments page when these settings differ by preview, production, or another named lane. ([link](/docs/config-environments)) +- **Need preview-scoped bindings?** โ€” Open the previews config page when preview deployments should own separate databases, buckets, or queues that can be cleaned up by scope later. ([link](/docs/config-previews)) +- **Need the production story?** โ€” The production deploy page covers explicit deploy targets and the inspection tools that belong beside them. ([link](/docs/production-deploys)) +- **Need preview behavior?** โ€” Preview strategy docs cover named preview scopes, same-worker uploads, and the Durable Object caveats around them. ([link](/docs/preview-strategies)) +- **Need app-route shape?** โ€” Open the routing page when the question is your route tree or request middleware, not Cloudflare deployment routes. ([link](/docs/http-routing)) + +--- + +### Use runtime helpers without passing the event through every function + +> Use explicit event parameters at handler boundaries, then use `getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, and `locals` inside helper code that runs during the same request or job. + +| Field | Value | +| --- | --- | +| Route | [`/docs/runtime-context`](/docs/runtime-context) | +| Group | Devflare | +| Navigation title | Runtime context | +| Eyebrow | Runtime helpers | + +The everyday rule is simple: accept the event in the handler, pass explicit data where it is clearer, and use runtime helpers when nested helper code needs the active request, env, context, event, or request-scoped `locals`. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Main rule | Event at the boundary, helpers inside the same handler trail | +| Main helpers | `getFetchEvent()`, `getQueueEvent()`, `getContext()`, `env`, `ctx`, `event`, and `locals` | +| Stored shape | `env`, `ctx`, `request`, `event`, and `locals` while a handler is active | +| Mutable lane | `locals` / `event.locals` | +| Failure mode | Strict runtime helpers throw outside an active handler trail | + +#### Pick the helper that matches where your code is running + +If `getFetchEvent()` or `env.DB` works in one helper and fails in another, first check whether that code still runs during the active request, job, or Durable Object call. + +Use per-surface getters when the helper needs the current event, use `env` or `ctx` when a helper only needs active bindings or execution context, and use `locals` for request-scoped data shared across middleware and helper calls. + +##### Reference table + +| Helper family | Examples | Use it for | +| --- | --- | --- | +| Per-surface getters | `getFetchEvent()`, `getQueueEvent()`, `getScheduledEvent()`, `getEmailEvent()`, `getTailEvent()` | Return the current rich event after verifying the active surface type; `.safe()` returns `null` instead of throwing. | +| Generic context getter | `getContext()` | Returns the active stored context shape when one exists and throws when code is running outside an active handler trail. | +| Readonly runtime proxies | `env`, `ctx`, `event` | Read the active environment bindings, execution context, or current event without threading parameters through every helper. | +| Mutable runtime proxy | `locals` | Reads and writes the per-request or per-job mutable storage object attached to the active context. | + +> **Important โ€” A practical reading guide** +> +> If the question in your head is โ€œwhen can I safely call `getFetchEvent()` or read `env` without passing the event around?โ€, the rest of this page is answering exactly that. + +#### Start with event-first handlers and let helpers discover the active event later + +Event-first handlers keep runtime state explicit at the boundary and still let nested helpers recover the current event later when plumbing it through every function call would be pure ceremony. That is the everyday job for helpers like `getFetchEvent()` and `locals`. + +In normal application code you should not need to establish runtime context manually. Devflare already does that for generated worker entrypoints, middleware, route dispatch, Durable Object wrappers, the dev server, and the built-in test helpers. + +##### Example โ€” Use the explicit event at the boundary and a getter inside the helper + +This keeps the handler honest while still letting helper code read the active request and shared locals later in the same call trail. + +###### File โ€” src/fetch.ts + +```ts +import { locals, type FetchEvent } from 'devflare/runtime' +import { currentPath } from './lib/current-path' + +export async function fetch(event: FetchEvent): Promise { + event.locals.requestId = crypto.randomUUID() + + return Response.json({ + path: currentPath(), + method: event.request.method, + requestId: String(locals.requestId) + }) +} +``` + +###### File โ€” src/lib/current-path.ts + +```ts +import { getFetchEvent } from 'devflare/runtime' + +export function currentPath(): string { + return getFetchEvent().url.pathname +} +``` + +#### Open internals only when helper setup is the problem + +The normal runtime-context page should help you write application code. Open the internals page only when you are changing runtime infrastructure, debugging helper setup, or checking how Devflare creates the active context. + +##### Highlights + +- **Runtime context internals** โ€” Read the stored context shape, setup steps, and advanced helper details. ([link](/docs/runtime-context-internals)) + +#### Getters and proxies are just different ways of reading the same store + +Pass the event explicitly at the top of the stack. Reach for getters or proxies only when helper code is still running in the same handler trail and threading that event downward would make the code noisier than the value it adds. + +This is also why strict runtime helpers throwing outside context is healthy: it stops top-level module code and random utility calls from pretending they are running inside a request when they are not. + +##### Reference table + +| API | What it reads | Failure behavior | Mutation | +| --- | --- | --- | --- | +| Handler parameters | The explicit event object Devflare passes to the handler boundary. | No lookup needed at the boundary. | `event.locals` is mutable. | +| Per-surface getters like `getFetchEvent()` | The stored `context.event` after Devflare verifies the active surface type. | Throws `ContextUnavailableError`, while `.safe()` returns `null`. | Readonly event view. | +| `getContext()` | The full active `RequestContext` object for the current handler trail. | Throws `ContextUnavailableError` outside an active handler trail. | Use this mostly for debugging or advanced infrastructure helpers. | +| `env`, `ctx`, `event` proxies | `getContextOrNull()` through readonly proxy wrappers. | Property access throws `ContextAccessError` outside an active handler trail. | Readonly. | +| `locals` proxy | `getContextOrNull()?.locals` through the mutable context proxy. | Property access throws `ContextAccessError` outside an active handler trail. | Mutable and shared with `event.locals`. | + +> **Important โ€” A simple rule** +> +> Use explicit handler parameters first, getters second, proxies third, and mutable `locals` only for data that truly belongs to the current request or job. + +#### Runtime helpers cover more than fetch + +Worker surfaces expose `event.ctx` as the current `ExecutionContext`. Durable Object surfaces expose `event.ctx` as the current `DurableObjectState`, and Devflare also aliases that same value as `event.state` for clarity. + +For fetch and Durable Object fetch, Devflare augments the actual `Request` instance. For queue, scheduled, email, tail, and Durable Object WebSocket surfaces, it augments the native carrier object instead of replacing it with a fantasy wrapper. + +Three general-purpose utilities round out the API: `hasContext()` checks whether a context is active, `getEventContext()` returns the current event regardless of surface type, and `getEventContextOrNull()` does the same but returns `null` outside a context. + +##### Reference table + +| Surface | Event shape | Getter | +| --- | --- | --- | +| HTTP worker | `FetchEvent` | `getFetchEvent()` | +| Queue consumer | `QueueEvent` | `getQueueEvent()` | +| Scheduled handler | `ScheduledEvent` | `getScheduledEvent()` | +| Inbound email | `EmailEvent` | `getEmailEvent()` | +| Tail handler | `TailEvent` | `getTailEvent()` | +| Durable Object fetch | `DurableObjectFetchEvent` | `getDurableObjectFetchEvent()` | +| Durable Object alarm | `DurableObjectAlarmEvent` | `getDurableObjectAlarmEvent()` | +| Durable Object WebSocket message / close / error | Dedicated WebSocket event types | `getDurableObjectWebSocketMessageEvent()`, `getDurableObjectWebSocketCloseEvent()`, `getDurableObjectWebSocketErrorEvent()` | +| Any Durable Object surface | `DurableObjectEvent` | `getDurableObjectEvent()` | + +#### `locals` is the mutable storage lane, and it is isolated per context + +Use `locals` for auth state, derived request data, request ids, or other values that belong to the current request or job and should be shared across middleware or helper layers. + +Within one handler trail, `locals` and `event.locals` point at the same underlying object. Across requests and jobs, each context gets a fresh locals object so state does not bleed between invocations. + +> **Warning โ€” Mutate `locals`, not the readonly proxies** +> +> `env`, `ctx`, and `event` are readonly runtime views. If you need shared mutable state, put it on `locals` instead of trying to assign back into the underlying context objects. + +##### Example โ€” Write to `event.locals`, read from `locals` later in the same trail + +###### File โ€” src/fetch.ts + +```ts +import { locals, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('x-request-id', String(locals.requestId)) + return next +} + +export const handle = sequence(requestId) +``` + +#### Context is not available everywhere, and that is intentional + +##### Key points + +- Module top-level code runs at cold start, not inside a request or job, so strict runtime helpers are unavailable there. +- Callbacks that run after the handler trail ends should take explicit inputs instead of assuming context is still alive. +- Timer callbacks like `setTimeout()` and `setInterval()` are outside the normal Devflare-managed handler trail. +- Per-surface getters and `getContext()` throw `ContextUnavailableError`, while proxy property access such as `env.DB` or `locals.userId` throws `ContextAccessError` naming the missing property. +- If you are unsure whether the matching surface is active, prefer `.safe()` accessors such as `getFetchEvent.safe()` over catching thrown errors. +- If runtime context access fails unexpectedly while bypassing Devflare-generated config or harnesses, open the runtime context internals page and verify the Worker still includes the compatibility flags Devflare normally adds for you. + +> **Note โ€” The fix is usually simpler than the error feels** +> +> Move the context access inside the handler, middleware, or helper that is called from that handler trail. If there is no active trail, take explicit inputs instead of hoping context exists. + +--- + +### How Devflare establishes runtime context + +> This page keeps the AsyncLocalStorage mechanics out of the normal usage guide while preserving them for maintainers and advanced debugging. + +| Field | Value | +| --- | --- | +| Route | [`/docs/runtime-context-internals`](/docs/runtime-context-internals) | +| Group | Devflare | +| Navigation title | Runtime internals | +| Eyebrow | Runtime internals | + +Use this page when helpers work in one runtime lane but not another, when you are changing runtime infrastructure, or when you need to verify exactly what Devflare stores while a handler is active. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Audience | Maintainers and advanced runtime debugging | +| Normal app page | `runtime-context` | +| Core primitive | `AsyncLocalStorage` | + +#### What Devflare stores while a handler is active + +Devflare creates `AsyncLocalStorage()` and stores more than the current request. The context includes environment bindings, execution context or Durable Object state, optional request, mutable locals, runtime surface type, and the original event object. + +That is why the higher-level runtime APIs can stay small. Per-surface getters return the stored event when the active surface matches, and the runtime proxies read the same context without forcing every helper to receive the event manually. + +> **Note โ€” The original event object is preserved** +> +> Devflare does not discard the richer surface event after extracting a request or context. The original event stays on `context.event`, which is what the per-surface getters read later. + +##### Example โ€” Simplified shape of the stored runtime context + +###### File โ€” src/runtime/context.ts + +```ts +type RequestContext = { + env: TEnv + ctx: ExecutionContext | DurableObjectState | null + request: Request | null + locals: Record + type: RuntimeEventType + event: EventContext +} +``` + +#### How Devflare creates and installs the context + +For fetch, queue, scheduled, email, tail, and Durable Object surfaces, Devflare first creates a rich event object using helpers such as `createFetchEvent()`, `createQueueEvent()`, or the Durable Object event builders. It then builds a `RequestContext` from that event and runs the handler trail inside `storage.run(...)`. + +The same mechanism is reused by generated worker entrypoints, request-wide middleware, route resolution, Durable Object wrappers, the dev server, and `createTestContext()` helpers such as `cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, and `cf.tail`. + +##### Steps + +1. Devflare builds the rich event object for the active surface. +2. It creates a `RequestContext` from `event.env`, `event.ctx`, `event.request ?? null`, `event.locals`, `event.type`, and the original event object. +3. It runs middleware, route resolution, or the surface handler inside `AsyncLocalStorage` with that context. +4. Helpers call getters or proxies, which read the current store instead of receiving the event manually. +5. When the handler trail ends, strict runtime helpers stop exposing context. + +##### Example โ€” The important part of `runWithEventContext()` is intentionally small + +###### File โ€” src/runtime/context.ts + +```ts +const context = { + env: event.env, + ctx: event.ctx, + request: event.request ?? null, + locals: event.locals, + type: event.type, + event +} + +return storage.run(context, fn) +``` + +#### `runWithEventContext()` and `runWithContext()` are infrastructure helpers + +By the time you are considering these helpers, the normal app-facing story should already be working: handlers, middleware, generated entrypoints, and `createTestContext()` establish context for you. These APIs exist for runtime and test infrastructure that must preserve or synthesize that context deliberately. + +`runWithEventContext(event, fn)` preserves an existing rich event object. `runWithContext(env, ctx, request, fn, type)` is the lower-level compatibility helper: it creates fresh locals, synthesizes a default event with `createDefaultEvent()`, and then stores that event before running your function. + +> **Warning โ€” Do not reach for the escape hatch by habit** +> +> If you are writing app code instead of runtime or test infrastructure, pass the event into your handler and let Devflare establish the context automatically. + +##### Example โ€” Wrap one infrastructure assertion with an existing event + +###### File โ€” src/test/runtime-context.ts + +```ts +import { getFetchEvent, runWithEventContext, type FetchEvent } from 'devflare/runtime' + +export async function readPathInsideContext(event: FetchEvent): Promise { + return runWithEventContext(event, async () => { + return getFetchEvent().url.pathname + }) +} +``` + +--- + +### Compose request-wide middleware with `sequence(...)` instead of burying flow control inside one big fetch file + +> Use `sequence(...)` from `devflare/runtime` when broad HTTP concerns must wrap route resolution or another fetch handler in a clear top-to-bottom order. + +| Field | Value | +| --- | --- | +| Route | [`/docs/sequence-middleware`](/docs/sequence-middleware) | +| Group | Devflare | +| Navigation title | sequence(...) | +| Eyebrow | Runtime helper | + +`sequence(...)` composes `(event, resolve)` middleware for workers so broad concerns stay readable without burying them in one monolithic fetch file. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Request-wide concerns that should wrap routes or another fetch handler cleanly | +| Primary signature | `(event, resolve) => Response` | +| Good pairing | `src/fetch.ts` plus `src/routes/**` leaf handlers | + +#### Use `sequence(...)` for the broad concerns that should wrap the whole HTTP flow + +The cleanest use of `sequence(...)` is broad request-wide behavior: CORS, auth guards, request ids, logging, response shaping, or any other concern that should wrap route resolution instead of being reimplemented in each leaf handler. + +That keeps `src/fetch.ts` focused on the global HTTP contract while route files stay small and URL-specific. + +##### Example โ€” A small global middleware chain + +###### File โ€” src/fetch.ts + +```ts +import { sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function cors(event: FetchEvent, resolve: ResolveFetch): Promise { + if (event.request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + }) + } + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('Access-Control-Allow-Origin', '*') + return next +} + +export const handle = sequence(cors) +``` + +###### File โ€” src/routes/users/[id].ts + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ params }: FetchEvent): Promise { + return Response.json({ id: params.id }) +} +``` + +#### Use the chain for broad concerns, not leaf business logic + +##### Highlights + +- **Good fit** โ€” CORS, auth checks, request ids, logging, response headers, or other concerns that should apply before or after the final leaf handler. +- **Usually the wrong fit** โ€” Business logic that only matters for one URL. If it is leaf-specific, keep it in the matched route file instead of global middleware. + +> **Important โ€” The split should stay boring** +> +> Global middleware should read like app policy. Route files should read like one URL at a time. If those blur together, the HTTP layer gets harder to review than it needs to be. + +#### Route files can export per-method handlers + +Route modules can export named functions for specific HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `ALL`. The runtime resolves the matching export based on the request method. + +`HEAD` requests fall back to `GET` when no `HEAD` export exists, and the response body is stripped automatically. `ALL` is the catch-all when no method-specific export matches. + +##### Key points + +- A handler with two parameters receives `(event, params)` as a convenience shorthand. +- A handler with an `(event, resolve)` signature is called in resolve-style, consistent with `sequence(...)` middleware. +- Method handlers resolve after the `sequence(...)` middleware chain. +- `default` exports are also supported: `export default { GET, POST }` or `export default function handle(event) { ... }`. + +##### Reference table + +| Export | Matches | Fallback behavior | +| --- | --- | --- | +| `GET` | `GET` requests | โ€” | +| `POST` | `POST` requests | โ€” | +| `PUT` | `PUT` requests | โ€” | +| `PATCH` | `PATCH` requests | โ€” | +| `DELETE` | `DELETE` requests | โ€” | +| `HEAD` | `HEAD` requests | Falls back to `GET` with body stripped | +| `ALL` | Any method not matched by a specific export | โ€” | + +#### Understand what `resolve(event)` actually means + +Calling `resolve(event)` continues into the next middleware in the chain, or into the matched route/module-level handler once no more middleware remains. That makes the order of the chain explicit instead of hidden inside nested helper calls. + +`resolve(event)` may also receive a replacement `FetchEvent`. That is the supported way for middleware to forward a modified request, preserved params, or updated locals into the next stage deliberately. + +##### Key points + +- `fetch` and `handle` are aliases for the primary fetch entry, so export one or the other, not both. +- Same-module method handlers and route resolution happen after the sequence chain passes control onward. +- If you are composing SvelteKit hooks, that uses SvelteKitโ€™s own `sequence` helper; it is a separate abstraction from `devflare/runtime` middleware composition. + +> **Warning โ€” One primary fetch entry per module** +> +> Devflare rejects ambiguous primary fetch modules. Export either `fetch` or `handle` (or one default equivalent), not several competing entrypoints. + +--- + +### Use event-first handlers by default and mark ambiguous handler styles explicitly + +> Devflare runtime supports event-first handlers, request-wide `sequence()` middleware, route method handlers, and explicit markers for ambiguous two-argument worker-style or resolve-style functions. + +| Field | Value | +| --- | --- | +| Route | [`/docs/runtime-handler-styles`](/docs/runtime-handler-styles) | +| Group | Devflare | +| Navigation title | Handler styles | +| Eyebrow | Runtime | + +This page documents `defineFetchHandler`, `sequence`, `markResolveStyle`, `markWorkerStyle`, event-first handlers, and route dispatch with examples that match the actual `devflare/runtime` exports. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Runtime import and dispatch questions | +| Worker-safe import | `devflare/runtime` | +| Ambiguous case | Two-argument fetch handlers | + +#### Copy the handler style that matches the job + +> **Warning โ€” The ambiguous error is intentional** +> +> If a two-argument handler is not marked, Devflare cannot safely know whether it is `(event, resolve)` or `(request, env)`. Mark it instead of relying on parameter names. + +##### Example โ€” Event-first and route-dispatch examples + +###### File โ€” src/fetch.ts + +```ts +import { defineFetchHandler, sequence, type FetchEvent, type ResolveFetch } from 'devflare/runtime' + +async function requestId(event: FetchEvent, resolve: ResolveFetch): Promise { + event.locals.requestId = crypto.randomUUID() + return resolve(event) +} + +export const handle = sequence(requestId) + +export const fetch = defineFetchHandler( + (request: Request, env: DevflareEnv) => env.ASSETS.fetch(request), + { style: 'worker' } +) +``` + +###### File โ€” src/routes/health.ts + +```ts +export function GET(): Response { + return Response.json({ ok: true }) +} + +export function POST(): Response { + return new Response(null, { status: 204 }) +} +``` + +###### File โ€” src/legacy.ts + +```ts +import { markResolveStyle, markWorkerStyle } from 'devflare/runtime' + +export const resolveStyle = markResolveStyle(async (event, resolve) => { + return resolve(event) +}) + +export const workerStyle = markWorkerStyle((request, env) => { + return env.ASSETS.fetch(request) +}) +``` + +--- + +### Use `src/transport.ts` when local RPC-style bridge calls must round-trip custom classes cleanly + +> Most workers do not need a transport file. Add one when Devflareโ€™s local RPC-style bridge must encode and decode custom values, especially across Durable Object method calls in tests. + +| Field | Value | +| --- | --- | +| Route | [`/docs/transport-file`](/docs/transport-file) | +| Group | Devflare | +| Navigation title | transport.ts | +| Eyebrow | Runtime transport | + +`src/transport.ts` is Devflareโ€™s custom serialization hook for local RPC-style bridge calls, especially the Durable Object round-trips Devflare manages in tests. It customizes the serialization layer for that bridge; it is not a replacement for ordinary fetch request or response handling. Its job is to let values that would otherwise collapse into plain JSON be rebuilt as real class instances on the caller side. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Bridge-backed Durable Object results that return custom classes | +| Usually unnecessary | Strings, numbers, arrays, and plain JSON objects | +| Disable rule | `files.transport: null` | + +#### Reach for it only when local RPC-style bridge calls must preserve real classes + +Most workers do not need a transport file because plain data already crosses the bridge naturally. + +Add `src/transport.ts` when a local RPC-style bridge call returns a custom class instance and you want the caller to receive that class again instead of a plain object. + +##### Highlights + +- **Good fit** โ€” A Durable Object method or another Devflare-managed RPC boundary returns a small domain value like `Money`, `DoubleableNumber`, or another class with behavior you want to keep intact. +- **Usually unnecessary** โ€” The handler or RPC call returns plain strings, numbers, arrays, or JSON objects that do not need custom decode logic. + +> **Note โ€” Think โ€œbridge-backed RPCโ€, not โ€œnormal JSON responsesโ€** +> +> This file matters when Devflare is proxying values across its local RPC bridge. It is not a replacement for ordinary Worker request or response serialization. + +#### Export one named `transport` object with small encode and decode pairs + +Keep each entry boring and explicit: detect one value shape, encode it into plain data, and decode that data back into the class on the caller side. + +##### Key points + +- Return `false` or `undefined` from `encode` when the value is not a match. +- Keep the encoded payload plain and JSON-friendly. +- Use one transport key per value type so decoding stays obvious in code review. + +##### Example โ€” Keep the transport file next to the class it knows how to round-trip + +The transport file teaches Devflare how to turn a custom class into plain data for the bridge, then rebuild that class for the caller. + +###### File โ€” src/DoubleableNumber.ts + +```ts +export class DoubleableNumber { + value: number + + constructor(value: number) { + this.value = value + } + + get double() { + return this.value * 2 + } +} +``` + +###### File โ€” src/transport.ts + +```ts +import { DoubleableNumber } from './DoubleableNumber' + +export const transport = { + DoubleableNumber: { + encode: (value: unknown) => + value instanceof DoubleableNumber ? value.value : false, + decode: (value: number) => new DoubleableNumber(value) + } +} +``` + +###### File โ€” src/do.counter.ts + +```ts +import { DoubleableNumber } from './DoubleableNumber' + +export class Counter { + private count = 0 + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +} +``` + +#### A tiny test is still the easiest proof of the round-trip + +> **Tip โ€” Keep the first proof small** +> +> If the transport works, you should be able to prove it with one class, one method call, and one `instanceof` assertion before you hide it inside bigger helpers. + +##### Example โ€” Test the round-trip, not just the numeric value + +###### File โ€” tests/counter.test.ts + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' +import { DoubleableNumber } from '../src/DoubleableNumber' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('custom transport restores the class instance', async () => { + const result = await env.COUNTER.getByName('main').increment(2) + + expect(result).toBeInstanceOf(DoubleableNumber) + expect(result.value).toBe(2) + expect(result.double).toBe(4) +}) +``` + +#### Know the autodiscovery and disable rules + +##### Key points + +- Use the conventional `src/transport.{ts,js,mts,mjs}` path when you want the default location. +- Use `files.transport` when the transport file lives somewhere else. +- Set `files.transport: null` when you want to disable the convention explicitly for a package. +- If the file exists but does not export a named `transport` object, Devflare warns and continues without custom transport decoding. + +> **Warning โ€” Do not treat the warning as success** +> +> If Devflare warns that the file does not export a named `transport` object, custom decode is off. The test may still run, but your class round-trip will not. + +##### Example โ€” Point at a custom transport path when the convention is not enough + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'transport-example', + files: { + fetch: 'src/fetch.ts', + transport: 'src/transport.ts' + } +}) +``` + +##### Example โ€” Disable transport autodiscovery explicitly + +###### File โ€” devflare.config.ts + +```ts +files: { + transport: null +} +``` + +--- + +### Why Devflare tests feel like using the worker instead of mocking around it + +> Devflareโ€™s standout testing trick is that the same config, bindings, env surface, runtime helpers, and even direct Durable Object method calls can stay available in Bun tests without a hand-built fake layer in the middle. + +| Field | Value | +| --- | --- | +| Route | [`/docs/why-testing-feels-native`](/docs/why-testing-feels-native) | +| Group | Devflare | +| Navigation title | Why tests feel native | +| Eyebrow | Testing advantage | + +The experience feels better because Devflare does more than boot Miniflare. `createTestContext()` loads the nearest config, wires the real worker surfaces, installs runtime-shaped helper entrypoints, and bridges Node or Bun test code back into the worker world so `env`, `cf.*`, and bridge-backed Durable Object calls keep the same mental model. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Key advantage | Tests can stay worker-shaped instead of mock-shaped | +| Core trick | `createTestContext()` plus a unified `env` proxy and bridge-backed bindings | +| Durable Object experience | Direct `env.COUNTER.getByName(...).increment()` calls in tests | +| Optional extra | `src/transport.ts` when bridge-backed calls must round-trip custom classes | + +#### The experience feels better because Devflare removes a whole fake layer + +A lot of Worker testing feels disconnected. One layer of code is written against real bindings and Worker surfaces, then the tests either fake those APIs by hand or retreat to heavier integration paths for everything. + +Devflare tries to keep one authored story instead. The same config that boots the app can boot the test harness, the same `env` import can keep working, and bridge-backed bindings can cross from Bun back into the worker world without forcing every test to speak raw HTTP or a custom mock vocabulary. + +##### Highlights + +- **One config** โ€” `createTestContext()` loads the same `devflare.config.*` model the app uses instead of a second test-only binding map. +- **One env surface** โ€” The unified `env` proxy uses request context in handlers, test context in tests, and the bridge when code needs to reach Miniflare-backed bindings. +- **One set of helper surfaces** โ€” `cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, and `cf.tail` trigger the same handler families your package actually owns. +- **One honest Durable Object story** โ€” Direct `env.MY_DO.getByName(...).method()` calls work in tests, so stateful code does not need a fake facade just to become testable. + +> **Important โ€” This is the key advantage** +> +> Devflare is at its best when a test can read like app code instead of a ceremony for building a fake Cloudflare universe first. + +#### The bridge is the difference, but it is not the only layer doing useful work + +The seamless part comes from several user-visible pieces cooperating: config autodiscovery, the unified `env` proxy, runtime-shaped helper entrypoints, and bridge proxies that forward binding calls into the local worker world. + +That is also why Devflare testing scales beyond one fetch route. The same system can cover direct binding calls, queue and scheduled helpers, Tail events, and bridge-backed Durable Object or service interactions without making you rewire the whole harness every time the package grows a new surface. + +##### Key points + +- Service binding refs and cross-worker Durable Object refs can trigger extra worker resolution automatically, so multi-worker tests still begin from the same config model. +- For single-worker tests, the bridge-backed env proxy is the normal path. For multi-worker refs, `createTestContext()` can boot the extra workers directly through Miniflare worker configuration. +- The bridge is there to remove translation pain, not to make the test vocabulary magical or mysterious. + +##### Reference table + +| Layer | What Devflare wires | Why it feels smoother | +| --- | --- | --- | +| `createTestContext()` | Finds the nearest config, boots Miniflare, discovers worker surfaces, and prepares bindings from the same authored project shape. | The harness starts where the app starts instead of from a separate test-only setup story. | +| Unified `env` proxy | Prefers request-scoped env, then test-context env, then bridge-backed env access. | One `import { env } from 'devflare'` can stay valid across app code, tests, and local bridge-backed flows. | +| `cf.*` helpers | Create runtime-shaped fetch, queue, scheduled, email, and tail events/controllers before user code runs. | Helpers such as `getFetchEvent()` and `locals` keep working in tests instead of only in real requests. | +| Bridge proxies | Route KV, D1, R2, Durable Object, queue, service, and send-email calls into the local worker world. | Bindings can be exercised through their real shapes instead of custom in-memory fakes. | +| Transport hooks | Optionally encode and decode custom values for local RPC-style bridge calls. | A Durable Object method can return a real class again on the caller side when that behavior matters. | + +#### This is the part that usually sells people: a Durable Object method can feel native in a test + +One of Devflare's nicest testing moves is that a Durable Object method can be called straight from the test through `env.COUNTER.getByName('main').increment(2)` instead of forcing you through a fake stub or an HTTP wrapper route. + +When the return value is more than plain JSON, `src/transport.ts` can keep the bridge honest by rebuilding the real class on the caller side. That is how a local test can still receive a `DoubleableNumber` with working instance behavior instead of a flattened object. + +> **Tip โ€” The bridge disappears when it is working well** +> +> That is the real win. You still benefit from the bridge, but the test itself mostly reads like โ€œboot the worker, call the thing, assert the domain value.โ€ + +##### Example โ€” The test reads like app code, not like bridge setup + +This mirrors the integration behavior Devflare proves itself: config autodiscovery, a direct Durable Object method call, and a custom class round-trip through `transport.ts`. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'counter-worker', + compatibilityDate: '2026-03-17', + files: { + durableObjects: 'src/do.counter.ts' + }, + bindings: { + durableObjects: { + COUNTER: { className: 'Counter', scriptName: 'do.counter.ts' } + } + } +}) +``` + +###### File โ€” src/DoubleableNumber.ts + +```ts +export class DoubleableNumber { + value: number + + constructor(value: number) { + this.value = value + } + + get double(): number { + return this.value * 2 + } +} +``` + +###### File โ€” src/transport.ts + +```ts +import { DoubleableNumber } from '../DoubleableNumber' + +export const transport = { + DoubleableNumber: { + encode: (value: unknown) => + value instanceof DoubleableNumber ? value.value : false, + decode: (value: number) => new DoubleableNumber(value) + } +} +``` + +###### File โ€” src/do.counter.ts + +```ts +import { DoubleableNumber } from '../DoubleableNumber' + +export class Counter { + private count = 0 + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +} +``` + +###### File โ€” tests/counter.test.ts + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' +import { DoubleableNumber } from '../src/DoubleableNumber' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('Durable Object methods feel native in tests', async () => { + const result = await env.COUNTER.getByName('main').increment(2) + + expect(result).toBeInstanceOf(DoubleableNumber) + expect(result.value).toBe(2) + expect(result.double).toBe(4) +}) +``` + +#### The same smooth story extends beyond plain HTTP + +That range is why the testing story feels bigger than one fetch helper. Devflare is not only helping you send requests; it is helping your tests talk to the same worker-owned surfaces your app logic actually depends on. + +When the package grows queues, schedules, email handlers, or Tail processing, the harness grows with the same worker-shaped mindset instead of forcing a whole new testing abstraction for each runtime surface. + +##### Highlights + +- **createTestContext()** โ€” Open this when the next question is the exact helper behavior, autodiscovery rules, or background-work timing. ([link](/docs/create-test-context)) +- **transport.ts** โ€” Open this when the next question is how to preserve real class instances across a local bridge-backed RPC call. ([link](/docs/transport-file)) +- **Binding testing guides** โ€” Jump here when the binding is already chosen and the only remaining question is the most honest test posture for that binding. ([link](/docs/binding-testing-guides)) + +##### Reference table + +| Surface | What the test calls | What Devflare keeps aligned | +| --- | --- | --- | +| Routes and fetch middleware | `cf.worker.get()` or `cf.worker.fetch()` | Request shape, route params, and runtime helper access. | +| Queue consumers | `cf.queue.trigger()` | Batch shape, retry or ack behavior, and queued `waitUntil()` work. | +| Scheduled jobs | `cf.scheduled.trigger()` | Cron controller shape, scheduled context, and background work timing. | +| Email and tail handlers | `cf.email.send()` and `cf.tail.trigger()` | Handler-style invocation with the right local helper semantics instead of custom throwaway scaffolding. | +| Bindings and Durable Object methods | `env.DB`, `env.CACHE`, `env.FILES`, or `env.COUNTER.getByName(...).increment()` | The same binding contract app code uses, optionally with transport-backed custom value round-trips. | + +#### Caveats worth knowing + +##### Key points + +- `cf.worker.fetch()` returns when the handler resolves, so some `waitUntil()` side effects may still be running afterward. +- `transport.ts` is for bridge-backed RPC-style calls, not a replacement for normal HTTP request or response serialization. +- Remote-heavy bindings such as AI and Vectorize still need higher-fidelity or remote checks sooner than KV, D1, R2, or many Durable Object flows do. +- Preview and CI validation still matter for Cloudflare ingress, routing, and deployment lifecycle questions that local tests do not pretend to answer completely. + +> **Warning โ€” Smooth local tests are the default, not the whole verification plan** +> +> Devflare makes honest local tests much easier, but it does not claim that every Cloudflare behavior is now a unit test. The strong story is โ€œless mocking, more truthful local coverage, then higher-fidelity checks when the question changes.โ€ + +--- + +### Use one testing map so you know which Devflare page answers which testing question + +> Devflareโ€™s testing story is layered: start with one real unit test, use `createTestContext()` and `cf.*` for the runtime-shaped harness, then jump to binding-specific guides or CI-focused pages only when the question changes. + +| Field | Value | +| --- | --- | +| Route | [`/docs/testing-overview`](/docs/testing-overview) | +| Group | Devflare | +| Navigation title | Testing overview | +| Eyebrow | Testing map | + +The docs already explain starter tests, harness behavior, runtime-context caveats, transport round-trips, binding-specific testing, and automation. This page gathers those lanes into one map so you can open the right testing page first instead of re-deriving the docs structure from memory. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Finding the right testing doc before you disappear into the wrong rabbit hole | +| Default harness | `createTestContext()` plus `cf.*` helpers | +| Binding-specific docs | At the bottom of each binding overview page and in the binding testing index | +| Automation lane | `/docs/testing-and-automation` for CI, preview checks, and workflow feedback | + +#### Start with one honest proof before you optimize the testing story + +The safest Devflare testing habit is boring: prove one worker path with one real request first, then only add more harness machinery when a binding, background surface, or preview concern genuinely needs it. + +The docs split testing into layers for this reason. A starter request test, a runtime-shaped harness page, binding-specific testing guides, and a CI/automation page each answer different questions. Trying to make one page carry all of that usually makes the guidance worse. + +##### Key points + +- If the worker cannot answer one truthful request, the next testing abstraction is probably not the rescue mission you need. +- Start route-level when the app behavior is the point, and binding-level when the binding itself is the point. +- Keep one small proof test around even after the suite grows so the runtime contract stays visible. + +##### Example โ€” The boring first loop is still the right default + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('GET /health proves the worker boots', async () => { + const response = await cf.worker.get('/health') + expect(response.status).toBe(200) +}) +``` + +#### Open the page that matches the question you actually have + +##### Highlights + +- **Why tests feel native** โ€” Open this when the question is less โ€œhow do I use the harness?โ€ and more โ€œwhy does Devflare testing feel so much smoother than the usual Worker setup?โ€ ([link](/docs/why-testing-feels-native)) +- **Your first unit test** โ€” Use this when the goal is simply to prove a worker boots, answers one request, and can be exercised through the real Devflare test harness. ([link](/docs/first-unit-test)) +- **createTestContext()** โ€” Use this when you need the real worker-shaped harness, autodiscovered surfaces, helper timing rules, and the `cf.*` testing helpers. ([link](/docs/create-test-context)) +- **Binding testing guides** โ€” Use this when the binding already exists and the open question is how to test KV, D1, R2, Queues, Durable Objects, AI, Vectorize, or another binding accurately. ([link](/docs/binding-testing-guides)) +- **Runtime context** โ€” Open this when missing-context errors, getters, or runtime proxies are making tests feel harder to trace than they should. ([link](/docs/runtime-context)) +- **transport.ts** โ€” Open this when a test needs a bridge-backed RPC call to return a real class instance instead of collapsing into plain JSON. ([link](/docs/transport-file)) +- **Testing & automation** โ€” Use this page when the question changes from local test harness behavior to CI workflows, preview checks, and observable automation. ([link](/docs/testing-and-automation)) + +#### The right testing layer depends on what changed + +##### Reference table + +| If the question is... | Open this page first | Why | +| --- | --- | --- | +| Can I prove the worker answers one real request? | `Your first unit test` | It keeps the first check small and prevents the harness from becoming accidental ceremony. | +| Why does Devflare testing feel smoother than the usual Worker setup? | `Why tests feel native` | It explains the unified env, bridge-backed bindings, runtime helper surfaces, and direct Durable Object story. | +| How does the default runtime-shaped harness behave? | `createTestContext()` | It documents autodiscovery, `cf.*`, helper timing, and when the harness waits for background work. | +| How should I test this specific binding? | `Binding testing guides` | Each binding has its own testing page with the right default harness and escalation path. | +| Why are getters or proxies failing in a test? | `Runtime context` | The runtime-context page explains when helper APIs can read the active request, env, ctx, event, and locals. | +| Why is a custom class not round-tripping in a test? | `transport.ts` | Transport docs explain the extra serialization hook for bridge-backed calls. | +| How should this fit into CI or preview validation? | `Testing & automation` | Automation guidance belongs on the CI-facing page, not in the local harness docs. | + +> **Note โ€” One page per question is a feature** +> +> Devflareโ€™s testing docs are intentionally split so starter tests, binding nuance, runtime context, and automation do not blur into one giant advice blob. + +#### Binding-specific testing pages already exist โ€” they were just easy to miss + +Each binding overview page already links its testing and example pages. That means the binding-specific testing content is already in the library, but it was discoverable mostly if you were already reading the right binding page. + +Use the binding testing index when you know which binding changed and want the testing guide directly. Use the binding overview page first when you still need the config shape, runtime usage, or local support notes before the tests make sense. + +##### Highlights + +- **Binding testing guides** โ€” Jump straight to the testing page for KV, D1, R2, Durable Objects, Queues, AI, Vectorize, Hyperdrive, Browser Rendering, Analytics Engine, or Send Email. ([link](/docs/binding-testing-guides)) + +##### Key points + +- Open the binding overview page when you need config or runtime context first. +- Open the binding testing page when the binding already exists and the question is purely about the right harness or escalation path. +- Remote-oriented bindings like AI and Vectorize deliberately have a different testing posture from KV or D1, and the testing guides say that out loud. + +--- + +### Use `createTestContext()` and `cf.*` as the default runtime-shaped test harness + +> Start tests with `createTestContext()` so the same config, bindings, routes, and handler surfaces the app uses in real runtime flows are available in Bun tests. + +| Field | Value | +| --- | --- | +| Route | [`/docs/create-test-context`](/docs/create-test-context) | +| Group | Devflare | +| Navigation title | createTestContext() | +| Eyebrow | Test harness | + +Devflareโ€™s recommended test story is not a pile of hand-built mocks. `createTestContext()` loads the nearest supported config, wires the local runtime surface, and gives you `cf.*` helpers that feel like the Worker entrypoints the app actually uses. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Runtime-shaped tests that should stay close to the real worker surface | +| Default harness | `createTestContext()` plus `cf.*` helpers | +| Optional extra | `src/transport.ts` for custom class round-trips across local RPC-style bridge calls, especially Durable Object methods | + +#### Let the harness discover the normal worker shape first + +When you omit the config path, `createTestContext()` walks upward from the calling test file and finds the nearest supported config filename. It then autodetects the conventional worker surfaces that belong to that package instead of making you wire each one by hand. + +That is the main reason the built-in harness scales: the same config and file conventions keep working as the package gains routes, queues, scheduled handlers, inbound email, or tail handlers. + +##### Key points + +- Config path autodiscovery starts from the calling test file when you omit the argument. +- Conventional files such as `src/fetch.ts`, `src/routes/**`, `src/queue.ts`, `src/scheduled.ts`, `src/email.ts`, and `src/tail.ts` are discovered automatically when present. +- Service bindings and other config-driven runtime surfaces are discovered from the same authored config instead of a separate test-only schema. +- If a local RPC-style bridge call under test later needs custom class round-trips, the harness can also discover `src/transport.{ts,js,mts,mjs}` automatically. + +#### Know which helpers wait for background work and which do not + +These helpers are runtime-shaped and context-accurate for handler logic, but they do not try to recreate every internal Cloudflare dispatch step byte for byte. Their timing rules are documented explicitly instead of being left to guesswork. + +##### Reference table + +| Helper | Current behavior | +| --- | --- | +| `cf.worker.fetch()` | Returns when the handler resolves and does not eagerly wait for all `waitUntil()` work. | +| `cf.queue.trigger()` | Waits for queued background work before it returns. | +| `cf.scheduled.trigger()` | Waits for scheduled background work before it returns. | +| `cf.email.send()` | In `createTestContext()` tests, directly invokes the configured local email handler and waits for its queued `waitUntil()` work; otherwise it falls back to the local email endpoint. | +| `cf.tail.trigger()` | Works when `src/tail.ts` exists, supports a default or named `tail` export, and waits for the handler plus its `waitUntil()` work before it returns. | + +> **Warning โ€” Do not assert the wrong timing contract** +> +> If a test depends on `waitUntil()` side effects being complete, a plain `cf.worker.fetch()` assertion may be too early. Either assert the side effect directly or move that check into a higher-fidelity path. + +#### Tail handlers are testable even before they become a public config lane + +Tail support is already a real helper surface in the harness even though it still sits outside the public `files.*` config keys. When `createTestContext()` finds `src/tail.ts`, it wires `cf.tail.trigger()` automatically and runs the handler with the same runtime helper access as the other test surfaces. + +The handler can export a default function or a named `tail` function. The helper accepts either full trace items or smaller option objects through `cf.tail.create(...)`, then waits for the handler and any queued `waitUntil()` work before it returns. + +##### Key points + +- Keep `src/tail.ts` as a conventional file for now; there is still no public `files.tail` config key. +- Use `cf.tail.create()` when the test only needs a few trace fields, and pass full trace items when the payload details are the point of the assertion. +- Reach for a higher-fidelity integration path when the question is Cloudflare ingress behavior rather than your own log or trace handling logic. + +> **Warning โ€” Supported helper, still a special-case surface** +> +> Tail support is real in the harness and runtime context model, but it is intentionally not documented like fetch, queue, scheduled, or email config yet because there is still no public `files.tail` key. + +##### Example โ€” A tiny tail handler plus one honest harness test + +###### File โ€” src/tail-state.ts + +```ts +export const seenScripts: string[] = [] +``` + +###### File โ€” src/tail.ts + +```ts +import type { TailEvent } from 'devflare/runtime' +import { seenScripts } from './tail-state' + +export async function tail({ events }: TailEvent): Promise { + for (const item of events) { + seenScripts.push(item.scriptName) + } +} +``` + +###### File โ€” tests/tail.test.ts + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' +import { seenScripts } from '../src/tail-state' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('tail handler sees trace items', async () => { + seenScripts.length = 0 + + const result = await cf.tail.trigger([ + cf.tail.create({ + scriptName: 'jobs-worker', + logs: [{ level: 'error', message: ['queue failed'], timestamp: Date.now() }] + }) + ]) + + expect(result.success).toBe(true) + expect(seenScripts).toEqual(['jobs-worker']) +}) +``` + +#### Start with one small proof test before layering helpers on top + +> **Tip โ€” Keep the first test boring** +> +> If the harness is working, you should be able to prove one route or handler path quickly before you hide it behind bigger factory helpers or shared test setup. + +##### Example โ€” A minimal runtime-shaped test + +###### File โ€” tests/worker.test.ts + +```ts +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +describe('worker runtime', () => { + test('routes through the built-in router', async () => { + const response = await cf.worker.get('/users/123') + expect(response.status).toBe(200) + }) +}) +``` + +#### Add `transport.ts` only when local RPC-style bridge calls in tests must preserve custom classes + +Most `createTestContext()` tests do not need a transport file because strings, numbers, arrays, and plain JSON objects already cross the bridge naturally. + +Reach for `src/transport.ts` when a local RPC-style bridge call returns a real class instance and the caller needs that class again instead of a plain object. In practice that is most often a Durable Object method round-trip inside `createTestContext()`, not an ordinary HTTP response. + +##### Key points + +- Keep the encoded payload plain and JSON-friendly. +- Use one small transport entry per value type so decode rules stay reviewable. +- Set `files.transport: null` when you want to disable the convention explicitly for one package. + +#### Know where to go when the harness is only part of the question + +##### Highlights + +- **Testing overview** โ€” Use the overview page when you are not sure whether the next question belongs to starter tests, binding-specific guides, runtime helpers, or CI. ([link](/docs/testing-overview)) +- **Binding testing guides** โ€” Jump straight to the binding-specific testing page when KV, D1, R2, Durable Objects, Queues, AI, or another binding needs a more specific test story. ([link](/docs/binding-testing-guides)) +- **Runtime context** โ€” Read this when getter failures, missing context, or proxy behavior are making the test harness harder to trace than it should be. ([link](/docs/runtime-context)) +- **Testing & automation** โ€” Use the CI-facing page when the question becomes preview validation, workflow structure, or what should happen in automation instead of local tests. ([link](/docs/testing-and-automation)) + +> **Note โ€” The harness is the center, not the whole map** +> +> `createTestContext()` is the default test loop, but binding-specific caveats, runtime-context rules, and automation concerns still belong on their own pages. + +--- + +### Open the right binding testing guide instead of reconstructing the test story from scratch + +> Every binding overview page already links a hidden testing guide. This page collects those guides in one place so you can jump straight to the right harness, caveats, and escalation path for the binding that changed. + +| Field | Value | +| --- | --- | +| Route | [`/docs/binding-testing-guides`](/docs/binding-testing-guides) | +| Group | Devflare | +| Navigation title | Binding testing | +| Eyebrow | Testing index | + +Binding testing is not one-size-fits-all. KV, D1, R2, Durable Objects, Queues, and several other bindings are strong local-first stories, while AI, Vectorize, and a few infrastructure-heavy bindings need more remote or higher-fidelity checks sooner. Use this page when you know the binding but do not want to hunt through the whole binding library first. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Jumping straight to the right binding-specific testing guide | +| Where the links also live | At the bottom of each binding overview page | +| Default pattern | Usually `createTestContext()` plus the real binding or helper surface | +| Notable exceptions | AI and Vectorize are remote-oriented, and some other bindings need higher-fidelity checks sooner | + +#### Use this page as the index, but remember where the links already live + +The binding library intentionally keeps only the main binding overview pages visible in the sidebar. The testing pages are still real docs pages, but they stay linked from the bottom of each binding overview so the sidebar does not turn into a twelve-level nesting doll. + +That is great once you already opened the right binding page. This index is for the opposite moment: you know the binding that changed and you want the testing guide immediately. + +##### Highlights + +- **Testing overview** โ€” Use the broader testing map when you are not yet sure whether the next question belongs to starter tests, binding guides, runtime context, or automation. ([link](/docs/testing-overview)) + +##### Key points + +- Open the binding overview page first when you need authoring, runtime, or preview context before the tests make sense. +- Open the testing guide first when the binding already exists and the only remaining question is how to test it. +- Use `Testing overview` when you need the bigger map across starter tests, harness behavior, binding guides, runtime helpers, and automation. + +#### Open the testing guide for the binding that actually changed + +##### Highlights + +- **Testing KV** โ€” Use the default test harness first. KV is one of the bindings Devflare supports best in local tests. Open the KV overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/kv/testing)) +- **Testing D1** โ€” D1 is one of the easiest bindings to test meaningfully with Devflare because the local runtime already speaks the same database API your worker uses. Open the D1 overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/d1/testing)) +- **Testing R2** โ€” R2 is local-friendly, which means you can test real object operations without inventing a storage adapter just to get off the ground. Open the R2 overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/r2/testing)) +- **Testing Durable Objects** โ€” Durable Objects are well-supported in the default Devflare harness, which means you can test real object behavior without hand-building a fake namespace first. Open the Durable Objects overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/durable-objects/testing)) +- **Testing Queues** โ€” Queue testing is one of the places where Devflareโ€™s helper surface feels especially good because the queue trigger already knows how to drive the real handler shape. Open the Queues overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/queues/testing)) +- **Testing Services** โ€” Service bindings are one of the clearest Devflare wins in multi-worker apps: you can keep the real worker boundary and still prove it through the default local harness. Open the Services overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/services/testing)) +- **Testing AI** โ€” The right AI test strategy is selective: use remote mode when you mean to test inference, and skip cleanly when the environment is not allowed to do that. Open the AI overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/ai/testing)) +- **Testing Vectorize** โ€” The right Vectorize tests are targeted remote checks: a small insert or query, a clear skip condition, and a real index behind the binding. Open the Vectorize overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/vectorize/testing)) +- **Testing Hyperdrive** โ€” Hyperdrive testing should start with a local connection string, then add a focused query test against the database shape your app actually uses. Open the Hyperdrive overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/hyperdrive/testing)) +- **Testing Browser Rendering** โ€” Browser tests should usually be integration-flavored: either drive the worker in dev or exercise a thin smoke path that proves the binding can launch and fetch. Open the Browser Rendering overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/browser-rendering/testing)) +- **Testing Analytics Engine** โ€” Analytics Engine tests should stay thin: verify that the worker writes a data point, not that you can recreate Cloudflare analytics locally. Open the Analytics Engine overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/analytics-engine/testing)) +- **Testing Send Email** โ€” Send Email is stronger locally than many platform-service bindings because outbound email can be exercised in the default harness, while inbound email has its own related helper surface. Open the Send Email overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/send-email/testing)) +- **Testing Rate Limiting** โ€” Test Rate Limiting by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Rate Limiting overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/rate-limiting/testing)) +- **Testing Version Metadata** โ€” Test Version Metadata by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Version Metadata overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/version-metadata/testing)) +- **Testing Worker Loaders** โ€” Test Worker Loaders by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Worker Loaders overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/worker-loaders/testing)) +- **Testing Secrets Store** โ€” Test Secrets Store by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Secrets Store overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/secrets-store/testing)) +- **Testing AI Search** โ€” Test AI Search by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the AI Search overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/ai-search/testing)) +- **Testing mTLS Certificates** โ€” Test mTLS Certificates by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the mTLS Certificates overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/mtls-certificates/testing)) +- **Testing Dispatch Namespaces** โ€” Test Dispatch Namespaces by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Dispatch Namespaces overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/dispatch-namespaces/testing)) +- **Testing Workflows** โ€” Test Workflows by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Workflows overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/workflows/testing)) +- **Testing Pipelines** โ€” Test Pipelines by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Pipelines overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/pipelines/testing)) +- **Testing Images** โ€” Test Images by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Images overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/images/testing)) +- **Testing Media Transformations** โ€” Test Media Transformations by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Media Transformations overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/media-transformations/testing)) +- **Testing Artifacts** โ€” Test Artifacts by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Artifacts overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/artifacts/testing)) +- **Testing Containers** โ€” Test Containers by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. Open the Containers overview first when you need the full binding story, or jump straight here when the only open question is how to test it. ([link](/docs/bindings/containers/testing)) + +#### The testing posture is not identical for every binding + +##### Reference table + +| Binding | Testing posture | Default harness | +| --- | --- | --- | +| KV | Local runtime and tests | `createTestContext()` plus `env.CACHE` or `cf.worker.fetch()` | +| D1 | Local runtime and tests | `createTestContext()` with `env.DB` or `cf.worker.fetch()` | +| R2 | Local runtime and tests | `createTestContext()` with `env.ASSETS` or `cf.worker.fetch()` | +| Durable Objects | Local runtime and tests, including cross-worker references | `createTestContext()` with the real DO namespace in `env` | +| Queues | Local runtime and queue-trigger tests | `createTestContext()` plus `cf.queue.trigger()` | +| Services | Local runtime and multi-worker tests | `createTestContext()` plus `env.MY_SERVICE` | +| AI | Remote-oriented; local tests require remote mode | `createTestContext()` after remote mode is enabled, plus `shouldSkip.ai` | +| Vectorize | Remote-oriented; local tests require remote mode or explicit mocks | `createTestContext()` in remote mode plus `shouldSkip.vectorize` | +| Hyperdrive | Full local support when Devflare has a local database connection string for the binding | `createTestContext()` or `createOfflineEnv()` with `localConnectionString` | +| Browser Rendering | Supported, but the strongest story is dev server and integration rather than a dedicated test helper | A narrow browser route exercised through the dev server, a preview URL, or another integration-style path | +| Analytics Engine | Supported, but usually tested through integration or thin mocks | A thin worker test or explicit mock around `writeDataPoint()` | +| Send Email | Outbound local support; distinct from inbound email event testing | `createTestContext()` plus `env.TRANSACTIONAL_EMAIL.send(...)` | +| Rate Limiting | Offline-native: Miniflare and Devflare pure mocks can exercise application-level rate limit behavior | `createTestContext()` or `createOfflineEnv()` | +| Version Metadata | Offline-native: Devflare can provide deterministic local metadata without Cloudflare state | `createTestContext()` or `createOfflineEnv()` | +| Worker Loaders | Full local support through Miniflare Worker Loader bindings and explicit pure-test Worker stubs | `createTestContext()` with explicit Worker payloads or a pure stub | +| Secrets Store | Full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values for pure tests | `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })` | +| AI Search | Offline-fixture: deterministic in-memory instances can test application flow, not hosted relevance behavior | `createOfflineEnv()` with AI Search fixtures | +| mTLS Certificates | Offline-fixture: local tests can model Fetcher behavior, but not real certificate presentation | `createOfflineEnv()` with `fixtures.mtlsCertificates` | +| Dispatch Namespaces | Offline-fixture: tests can provide named tenant fetchers, but Devflare does not emulate tenant upload/lifecycle | `createOfflineEnv()` with `fixtures.dispatchNamespaces` | +| Workflows | Full local support through Miniflare workflow bindings and deterministic workflow mocks | `createTestContext()` or `createOfflineEnv()` | +| Pipelines | Offline-native for send-recording tests; Cloudflare owns production batching and sink delivery | `createTestContext()` or `createOfflineEnv()` | +| Images | Full local support through Miniflare image bindings, persisted local state, and deterministic pure mocks | `createTestContext()` or `createOfflineEnv()` | +| Media Transformations | Full local support through Miniflare media bindings and deterministic pure mocks for transform chains | `createTestContext()` or `createOfflineEnv()` with media fixtures | +| Artifacts | Offline-fixture: repo metadata and token flows can be modeled in memory, not as real Git remotes | `createOfflineEnv()` with artifact fixtures | +| Containers | Offline-native only when an explicit Docker/Podman engine is available and the image can run without pulling | `devflare/test` containers helpers guarded by `shouldSkip.containers` | + +> **Warning โ€” Different defaults are a good thing** +> +> KV, D1, R2, and Queues should not be documented like remote AI inference, and remote AI inference should not be documented like local KV. The different testing guides are there to keep those truths visible. + +#### Copy the smallest helper that matches the boundary + +Pick the helper from the thing you need to prove. Use pure mocks for small functions, `createOfflineEnv()` when config-derived binding names matter, `createTestContext()` when the Worker surface matters, and skip-gated lanes when Docker/Podman or Cloudflare credentials are part of the test. + +##### Reference table + +| Need to prove | Start with | Runs in ordinary CI? | +| --- | --- | --- | +| A pure function calls one binding method | `createMockEnv()` or a specific `createMock*` helper | Yes | +| The env should match `devflare.config.ts` without booting Miniflare | `createOfflineEnv()` | Yes | +| A Worker route, queue, scheduled, email, tail, or service flow works | `createTestContext()` plus `cf.*` | Yes, unless the feature itself needs a remote boundary | +| Docker/Podman, Cloudflare auth, or deployed behavior is the point | `shouldSkip.*` plus a separate integration lane | Only when the runner has the dependency | + +##### Example โ€” Four helper lanes in one test file + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { + cf, + createMockEnv, + createOfflineEnv, + createTestContext, + env, + shouldSkip +} from 'devflare/test' +import config from '../devflare.config' + +test('pure binding logic uses a mock env', async () => { + const env = createMockEnv({ kv: { CACHE: 'CACHE' } }) + await env.CACHE.put('key', 'value') + expect(await env.CACHE.get('key')).toBe('value') +}) + +test('config-derived offline tests keep real binding names', () => { + const env = createOfflineEnv(config, { + secretsStore: { + API_TOKEN: 'test-token' + } + }) + + expect(env.API_TOKEN).toBeDefined() +}) + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('worker behavior uses the runtime-shaped harness', async () => { + const response = await cf.worker.get('/health') + expect(response.status).toBe(200) +}) + +const skipContainers = await shouldSkip.containers + +test.skipIf(skipContainers)('container tests are explicit opt-in lanes', async () => { + expect(skipContainers).toBe(false) +}) +``` + +--- + +### Document every public `devflare/test` helper by the smallest useful use + +> Use this reference when you know you need the test package but not which helper surface is the smallest truthful proof. + +| Field | Value | +| --- | --- | +| Route | [`/docs/test-helper-reference`](/docs/test-helper-reference) | +| Group | Devflare | +| Navigation title | Test helper reference | +| Eyebrow | Testing | + +The `devflare/test` entrypoint intentionally has multiple lanes: runtime-shaped tests, direct event helpers, pure mocks, offline envs, remote-boundary guards, and Docker/Podman-gated container helpers. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Choosing and importing test helpers | +| Default import | `import { cf, createTestContext, env } from "devflare/test"` | +| Cleanup | `afterAll(() => env.dispose())` | + +#### Helper map + +##### Reference table + +| Export family | Smallest use | Status | +| --- | --- | --- | +| `createTestContext`, `env`, `cf` | Runtime-shaped Worker tests with cleanup. | Recommended | +| `cf.worker`, `cf.queue`, `cf.scheduled`, `cf.email`, `cf.tail` | Trigger the matching Worker surface directly. | Recommended | +| `worker`, `queue`, `scheduled`, `email`, `tail` | Direct helper modules behind the unified `cf` API. | Advanced | +| `createOfflineEnv`, `createOfflineBindings`, `describeOfflineSupport`, `getOfflineSupportMatrix` | Pure config-derived binding fixtures without runtime startup. | Recommended for offline-first unit tests | +| `createMockKV`, `createMockD1`, `createMockR2`, `createMockQueue`, `createMockEnv` | Small pure unit tests without Miniflare. | Recommended when runtime dispatch is irrelevant | +| `createMockRateLimit`, `createMockVersionMetadata`, `createMockWorkerLoader`, `createMockSecretsStoreSecret` | Pure fixture for one platform-shaped binding. | Recommended | +| `createMockMTLSCertificate`, `createMockDispatchNamespace`, `createMockWorkflow`, `createMockPipeline` | Call-shape tests for platform-owned products. | Boundary-aware | +| `createMockImagesBinding`, `createMockMediaBinding`, `createMockArtifacts`, AI Search mocks | Deterministic local tests for product-shaped APIs. | Boundary-aware | +| `shouldSkip` | Skip remote, paid, or dependency-heavy checks clearly. | Recommended for CI | +| `containers`, `createContainerManager`, `detectContainerEngine`, `getContainerSkipReason`, `stopActiveContainers` | Docker/Podman-gated local container tests. | Integration lane | +| `resolveServiceBindings`, `resolveDOBindings`, `clearBundleCache` | Service-binding and cross-worker DO resolution internals. | Advanced/internal | + +#### Exact value export index + +##### Reference table + +| Export | Use | +| --- | --- | +| `createTestContext` | Boot the nearest Devflare config in the test harness. | +| `env` | Read bindings and call `env.dispose()` in harness tests. | +| `cf` | Unified Worker, queue, scheduled, email, and tail trigger API. | +| `worker` | Direct Worker fetch helper behind `cf.worker`. | +| `queue` | Direct queue helper behind `cf.queue`. | +| `scheduled` | Direct scheduled helper behind `cf.scheduled`. | +| `email` | Direct email helper behind `cf.email`. | +| `tail` | Direct tail helper behind `cf.tail`. | +| `shouldSkip` | Skip Cloudflare-auth, paid, remote, or local engine tests explicitly. | +| `containers` | Default Docker/Podman-backed container manager. | +| `createContainerManager` | Create an isolated container manager for tests. | +| `detectContainerEngine` | Check whether Docker or Podman can run. | +| `getContainerSkipReason` | Explain why a container test should skip. | +| `stopActiveContainers` | Stop containers after tests finish. | +| `createOfflineBindings` | Build pure binding fixtures from config. | +| `createOfflineEnv` | Build an env object for offline-first unit tests. | +| `describeOfflineSupport` | Read one binding family support stance. | +| `getOfflineSupportMatrix` | Read the full offline support stance map. | +| `createMockAISearchInstance` | Mock one AI Search instance. | +| `createMockAISearchNamespace` | Mock an AI Search namespace. | +| `createMockTestContext` | Pure test context helper for small units. | +| `withTestContext` | Scope a pure context to one callback. | +| `createMockKV` | Mock KV for pure units. | +| `createMockD1` | Mock D1 for pure units. | +| `createMockR2` | Mock R2 for pure units. | +| `createMockQueue` | Mock a Queue producer. | +| `createMockRateLimit` | Mock Rate Limiting. | +| `createMockVersionMetadata` | Mock Version Metadata. | +| `createMockWorkerLoader` | Mock Worker Loaders. | +| `createMockMTLSCertificate` | Mock an mTLS fetcher. | +| `createMockDispatchNamespace` | Mock a dispatch namespace. | +| `createMockWorkflow` | Mock a Workflow binding. | +| `createMockPipeline` | Mock a Pipelines binding. | +| `createMockImagesBinding` | Mock Images chains. | +| `createMockMediaBinding` | Mock Media Transformation chains. | +| `createMockArtifacts` | Mock Artifacts repo APIs. | +| `createMockSecretsStoreSecret` | Mock a Secrets Store secret. | +| `createMockEnv` | Create a pure env with selected mock bindings. | +| `hasServiceBindings` | Advanced/internal service-binding resolution predicate. | +| `resolveServiceBindings` | Advanced/internal service-binding resolution. | +| `hasCrossWorkerDOs` | Advanced/internal cross-worker Durable Object predicate. | +| `resolveDOBindings` | Advanced/internal Durable Object binding resolution. | +| `clearBundleCache` | Advanced/internal resolver cache reset for tests. | + +#### Copy the default helper shape + +##### Example โ€” Worker, event, offline, and boundary tests + +###### File โ€” tests/offline-env.test.ts + +```ts +import { expect, test } from 'bun:test' +import { createOfflineEnv, describeOfflineSupport } from 'devflare/test' +import config from '../devflare.config' + +test('offline env is enough for pure binding logic', async () => { + const support = describeOfflineSupport('kv') + const env = createOfflineEnv(config, { + kv: { + CACHE: 'CACHE' + } + }) + + await env.CACHE.put('hello', 'offline') + + expect(support.tier).not.toBe('remote-only') + expect(await env.CACHE.get('hello')).toBe('offline') +}) +``` + +###### File โ€” tests/remote-boundary.test.ts + +```ts +import { expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +test.skipIf(await shouldSkip.ai)('AI uses the real remote boundary', async () => { + await createTestContext() + try { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply OK' }] + }) + + expect(result).toBeDefined() + } finally { + await env.dispose() + } +}) +``` + +###### File โ€” tests/container.test.ts + +```ts +import { afterAll, expect, test } from 'bun:test' +import { containers, shouldSkip, stopActiveContainers } from 'devflare/test' + +afterAll(() => stopActiveContainers()) + +const skipContainers = await shouldSkip.containers + +test.skipIf(skipContainers)('container responds without pulling in CI', async () => { + const app = await containers.start('ApiContainer', { + image: 'devflare-fixture:local', + port: 8080, + offline: true + }) + + expect(await app.fetch('/health').then((response) => response.status)).toBe(200) +}) +``` + +#### Expected failure and skip behavior + +##### Reference table + +| Failure or skip | Meaning | Fix | +| --- | --- | --- | +| `No devflare config found` | `createTestContext()` could not discover a supported config from the test file. | Pass the config path or move the test under the package root. | +| `env.dispose is not a function` | The test imported the runtime env proxy instead of the test env. | Use `import { env } from "devflare/test"` in tests. | +| `shouldSkip.ai` is true | Cloudflare auth or remote AI prerequisites are missing. | Keep the test skipped in local/CI, or enable remote mode in a dedicated lane. | +| `shouldSkip.containers` is true | Docker/Podman is missing or not usable in this runner. | Install an engine or keep container tests in an optional integration job. | + +--- + +### Render Svelte inside worker bundles by putting the compiler in Rolldown, not the app shell + +> When a worker-only fetch surface or Durable Object imports `.svelte`, add the Svelte compiler to `rolldown.options.plugins`. That compilation belongs to Devflareโ€™s worker bundler, not the main Vite plugin chain. + +| Field | Value | +| --- | --- | +| Route | [`/docs/svelte-with-rolldown`](/docs/svelte-with-rolldown) | +| Group | Devflare | +| Navigation title | Svelte in workers | +| Eyebrow | Frameworks | + +This is the right path when the worker itself renders or consumes Svelte components. Keep the package in worker-only mode if that is all you need, then extend Devflareโ€™s Rolldown pipeline with the Svelte plugins that make those imports compile cleanly. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Worker-only fetch surfaces or Durable Objects that import `.svelte` | +| Key extension point | `rolldown.options.plugins` | +| Rendering shape | SSR-style component compilation inside the worker bundle | + +#### Use this path when the worker imports the component + +If your worker entry, route module, queue consumer, scheduled handler, or Durable Object imports a `.svelte` file directly, Devflare treats that as a worker bundling concern. The correct place to teach the build how to compile it is the Rolldown pipeline that Devflare owns for worker bundles. + +That means you do not need to promote the whole package into a Vite app just because one worker module wants Svelte-based rendering. Worker-only mode remains the intended default until the package truly needs an outer app host. + +> **Note โ€” Keep the ownership line clean** +> +> Vite owns the outer app shell when one exists. Rolldown owns the worker code that Devflare bundles itself. Worker-rendered Svelte belongs to the second bucket. + +#### Add Svelte to Rolldown options + +##### Key points + +- `emitCss: false` keeps the worker bundle single-file instead of emitting a CSS asset pipeline the worker cannot naturally serve by itself. +- `generate: `ssr`` fits worker-side rendering better than a browser DOM target. +- `@rollup/plugin-node-resolve` helps `.svelte` files and `exports.svelte` packages resolve cleanly. + +##### Example โ€” Install the worker-side Svelte toolchain + +```bash +bun add -d svelte rollup-plugin-svelte @rollup/plugin-node-resolve +``` + +##### Example โ€” Configure Svelte in `rolldown.options.plugins` + +```ts +import { defineConfig } from 'devflare/config' +import resolve from '@rollup/plugin-node-resolve' +import type { Plugin as RolldownPlugin } from 'rolldown' +import svelte from 'rollup-plugin-svelte' + +export default defineConfig({ + name: 'chat-worker', + files: { + fetch: 'src/fetch.ts' + }, + rolldown: { + sourcemap: true, + options: { + plugins: [ + svelte({ + emitCss: false, + compilerOptions: { + generate: 'ssr' + } + }) as unknown as RolldownPlugin, + resolve({ + browser: true, + exportConditions: ['svelte'], + extensions: ['.svelte'] + }) as unknown as RolldownPlugin + ] + } + } +}) +``` + +#### Render from the worker like any other module import + +> **Warning โ€” Do not over-generalize the plugin stack** +> +> If a plugin depends on Rollup-only hooks that Rolldown does not support yet, keep that plugin in the main Vite build instead of the worker bundler. + +##### Example โ€” `src/Greeting.svelte` + +###### File โ€” src/Greeting.svelte + +```svelte + + +

Hello {name} from Svelte

+``` + +##### Example โ€” `src/fetch.ts` + +```ts +import Greeting from './Greeting.svelte' + +export async function fetch(): Promise { + return new Response(Greeting.render({ name: 'Devflare' }).html, { + headers: { + 'content-type': 'text/html; charset=utf-8' + } + }) +} +``` + +--- + +### Use Devflare with a standalone Vite app when Vite is the outer host and Devflare owns Worker config underneath + +> An effective Vite config is what opts the package into Vite-backed flows: a local `vite.config.*`, a non-empty `config.vite`, or both together. Use `devflare/vite` when the package really is a Vite app and you want Devflare to keep Worker config, Durable Objects, and generated Wrangler output aligned underneath it. + +| Field | Value | +| --- | --- | +| Route | [`/docs/vite-standalone`](/docs/vite-standalone) | +| Group | Devflare | +| Navigation title | Vite standalone | +| Eyebrow | Frameworks | + +This is the lane for frontend-first packages that already have a real Vite app shell. Vite keeps HMR and the app build. Devflare plugs generated Worker config, Durable Object discovery, bridge behavior, and Worker-aware artifacts into that pipeline. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Standalone Vite apps that still ship Worker-aware runtime pieces | +| Mode switch | Local `vite.config.*` or non-empty `config.vite` | +| Primary helper | `devflare/vite` | + +#### Know what actually enables Vite-backed mode + +##### Key points + +- A local `vite.config.*` opts the current package into Vite-backed flows. +- A non-empty `config.vite` also opts the package into Vite-backed flows. +- Vite dependencies by themselves do not switch the package out of worker-only mode. +- Without an effective Vite config, `dev`, `build`, and `deploy` stay worker-only. + +> **Tip โ€” Worker-only is still the default** +> +> Use Vite because the package has a real Vite host, not because it feels like every modern project should have one glued on top. + +#### Choose the lightest wiring that fits the app + +Use the minimal plugin shape when this file only needs to add Devflareโ€™s Worker-aware behavior and the rest of the Cloudflare Vite wiring already lives elsewhere. Reach for `getDevflareConfigs()` when this file should own the Cloudflare plugin configuration explicitly too. + +##### Example โ€” Minimal Devflare-side Vite integration + +```ts +import { defineConfig } from 'vite' +import { devflarePlugin } from 'devflare/vite' + +export default defineConfig({ + plugins: [devflarePlugin()] +}) +``` + +##### Example โ€” Explicit Cloudflare plugin wiring + +```ts +import { defineConfig } from 'vite' +import { cloudflare } from '@cloudflare/vite-plugin' +import { devflarePlugin, getDevflareConfigs } from 'devflare/vite' + +export default defineConfig(async () => { + const { cloudflareConfig, auxiliaryWorkers } = await getDevflareConfigs() + + return { + plugins: [ + devflarePlugin(), + cloudflare({ + config: cloudflareConfig, + auxiliaryWorkers: auxiliaryWorkers.length > 0 ? auxiliaryWorkers : undefined + }) + ] + } +}) +``` + +#### `devflarePlugin()` options + +##### Reference table + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `configPath` | `string` | `devflare.config.ts` | Path to the Devflare config file. | +| `environment` | `string` | โ€” | Named environment from config to resolve. | +| `doTransforms` | `boolean` | `true` | Enable Durable Object code transforms. | +| `watchConfig` | `boolean` | `true` | Watch the config file for changes in dev mode. | +| `bridgePort` | `number` | `DEVFLARE_BRIDGE_PORT` | Miniflare bridge port for WebSocket proxying. | +| `wsProxyPatterns` | `string[]` | `[]` | Additional patterns to proxy WebSocket requests to Miniflare. Patterns from `wsRoutes` in config are included automatically. | + +#### Know what changes once Vite is actually active + +The package still uses the same Devflare command loop. What changes is the outer host: Vite takes over the app shell while Devflare keeps resolving worker config, generated Wrangler output, Durable Object discovery, and composed worker entrypoints underneath it. + +That means you should think in terms of host ownership, not a separate CLI mode. Reach for this page when the package genuinely became a Vite app, not when you just need one more bundler-shaped knob. + +##### Steps + +1. Devflare loads and validates `devflare.config.*` first. +2. If a local `vite.config.*` exists, Devflare loads it and overlays `config.vite` on top; otherwise it can synthesize `.devflare/vite.config.mjs` from `config.vite` alone. That merged result is the effective Vite config. +3. Devflare still compiles worker-aware config into generated Wrangler output and may generate `.devflare/worker-entrypoints/main.ts` when worker surfaces need wrapper glue or composition. +4. Build and deploy use the current package's installed Vite so the outer app build and the inner worker plumbing stay aligned. + +> **Note โ€” Same commands, different host** +> +> You do not learn a second CLI vocabulary for Vite-backed packages. The config decides who hosts the outer app, while the Devflare commands stay familiar. + +#### Keep ownership lines obvious + +##### Highlights + +- **Vite owns** โ€” The outer app dev server, HMR, and the app build for packages that are truly Vite apps. +- **Devflare owns** โ€” Generated Wrangler config, composed worker entrypoints, Durable Object discovery, bridge behavior, and worker-aware build glue. +- **Generated output** โ€” Treat `.devflare/vite.config.mjs` and `.devflare/wrangler.jsonc` as output, not as the source of truth you maintain by hand. + +##### Key points + +- If both `vite.config.*` and `config.vite` exist, Devflare merges `vite.config.*` first and then overlays `config.vite`. +- `wrangler.passthrough.main` is the explicit opt-out if you want to own the Worker main entry completely. + +--- + +### Compose Devflare with SvelteKit by letting SvelteKit host the app and Devflare supply the Worker platform + +> Hand SvelteKit's Cloudflare adapter output to Devflare via `wrangler.passthrough.main` (the adapter worker is a build artifact and does not exist until `vite build` runs), keep `sveltekit()` in `vite.config.ts`, and compose `devflare/sveltekit` into `src/hooks.server.ts` so local platform bindings line up with the Worker runtime Devflare manages. + +| Field | Value | +| --- | --- | +| Route | [`/docs/sveltekit-with-devflare`](/docs/sveltekit-with-devflare) | +| Group | Devflare | +| Navigation title | SvelteKit | +| Eyebrow | Frameworks | + +This is the path for full SvelteKit apps where the framework owns the outer shell and Devflare keeps the Worker-facing platform story coherent. It matches the repositoryโ€™s real documentation app and the SvelteKit integration example in the public docs. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Full SvelteKit apps that deploy through Devflare | +| Worker entry | The adapter worker output your package actually emits, commonly `.svelte-kit/cloudflare/_worker.js` or a repo-specific path such as `.adapter-cloudflare/_worker.js`, wired via `wrangler.passthrough.main` | +| Hook helper | `devflare/sveltekit` | + +#### Wire the SvelteKit package like a SvelteKit app first + +SvelteKit still owns the app shell, routing, and framework build. Devflare plugs Worker-aware config, generated Wrangler output, and any Durable Object discovery into that Vite-driven flow. + +The adapter worker is a **build artifact** โ€” `@sveltejs/adapter-cloudflare` only writes `.svelte-kit/cloudflare/_worker.js` (or your repo's equivalent, like `.adapter-cloudflare/_worker.js`) during `vite build`. Devflare resolves handler paths *before* the framework build runs, so pointing `files.fetch` at that path fails on a clean checkout with `Configured fetch handler "โ€ฆ" was not found`. Use `wrangler.passthrough.main` instead: devflare skips composition entirely for the worker entry, and wrangler picks up the adapter output post-build. + +If you also have queue handlers, scheduled handlers, durable objects, or routes, keep those in `files.queue` / `files.scheduled` / `files.durableObjects` / `files.routes` as normal source files โ€” composition still applies to those surfaces. + +##### Example โ€” `devflare.config.ts` + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'notes-app', + files: { + // fetch is supplied by SvelteKit's adapter output below; + // keep this false so devflare does not try to compose around an unbuilt artifact. + fetch: false, + durableObjects: 'src/do/**/*.ts' + }, + wrangler: { + passthrough: { + // SvelteKit's @sveltejs/adapter-cloudflare writes this file during vite build. + main: '.svelte-kit/cloudflare/_worker.js' + } + } +}) +``` + +##### Example โ€” `vite.config.ts` + +```ts +import { defineConfig } from 'vite' +import { sveltekit } from '@sveltejs/kit/vite' +import { devflarePlugin } from 'devflare/vite' + +export default defineConfig({ + plugins: [devflarePlugin(), sveltekit()] +}) +``` + +#### Put the Devflare handle at the front of `hooks.server.ts` + +> **Important โ€” Why the order matters** +> +> The Devflare handle is the piece that prepares `event.platform` in local dev. Put it first so later middleware sees the same platform shape the app expects. + +##### Example โ€” Simple composed handle + +```ts +import { sequence } from '@sveltejs/kit/hooks' +import { handle as devflareHandle } from 'devflare/sveltekit' + +const authHandle = async ({ event, resolve }) => resolve(event) + +export const handle = sequence(devflareHandle, authHandle) +``` + +##### Example โ€” Custom handle with explicit binding hints + +```ts +import { sequence } from '@sveltejs/kit/hooks' +import { createHandle } from 'devflare/sveltekit' + +const devflareHandle = createHandle({ + hints: { + DB: 'd1', + CACHE: 'kv', + CHAT_ROOM: 'do' + } +}) + +export const handle = sequence(devflareHandle) +``` + +#### Reach for `createHandle()` only when the simple handle is not enough + +##### Key points + +- Use the exported `handle` from `devflare/sveltekit` when auto-loaded binding hints from `devflare.config.ts` are enough. +- Use `createHandle()` when you need custom binding hints, a custom bridge URL, or a custom `shouldEnable()` rule. +- If your repo already points `wrangler.passthrough.main` at the adapter worker, keep that path authoritative instead of duplicating it in `files.fetch`. +- Keep the rest of the app in normal SvelteKit patterns; Devflare is there to supply the Worker platform and config alignment, not to replace SvelteKit itself. + +--- + +### Official GitHub Actions patterns for Devflare + +> Devflare ships reusable GitHub Actions for setup, impact checks, deploy execution, and feedback, plus supported workflow strategies for validation, previews, production, and cleanup. + +| Field | Value | +| --- | --- | +| Route | [`/docs/github-workflows`](/docs/github-workflows) | +| Group | Ship & operate | +| Navigation title | GitHub workflows | +| Eyebrow | CI/CD | + +Treat GitHub workflows as policy and target selection. Treat the reusable Devflare actions as the supported mechanics for workspace setup, impact checks, explicit deploys, and GitHub feedback. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | GitHub Actions with validation, preview, production, and cleanup lanes | +| Supported actions | 4 reusable actions | +| Package selector | `working-directory` picks which Devflare config deploys | + +#### GitHub Actions are a supported deployment surface + +This page is the reference for running Devflare from GitHub Actions. The reusable actions and workflow shapes in this repository are the supported CI/CD patterns, not incidental excerpts copied out of one lucky workflow. + +Keep the ownership split sharp: workflows decide when a lane runs, which permissions it gets, which package it targets, and what verification happens afterwards. The reusable Devflare actions own the mechanics that should stay consistent across repositories. + +##### Key points + +- Inside this repository, use local action paths like `./.github/actions/devflare-deploy`. +- From another repository, use `Refzlund/devflare/.github/actions/@next`. +- Make the target package visible through `working-directory` instead of hiding package selection in a shell wrapper. +- Keep validation, preview, production, and cleanup lanes explicit. They have different verification rules for good reasons. + +##### Reference table + +| Layer | Owns | Should not own | +| --- | --- | --- | +| Workflow file | Triggers, permissions, concurrency, package selection, and verification order. | Deploy argument construction, Bun setup, or PR comment formatting. | +| `devflare-setup-workspace` | Bun installation, cache restore, and one shared workspace install. | Target selection or any deploy step. | +| `devflare-deploy-impact` | Change detection for one deployment target. | Cloudflare deploys or GitHub reporting. | +| `devflare-deploy` | One explicit production or named preview-scope deploy. | PR comment policy or multi-package orchestration. | +| `devflare-github-feedback` | PR comments, deployment records, and inactive cleanup updates. | Cloudflare deploy execution. | + +> **Note โ€” The goal** +> +> Reusable mechanics, explicit policy, and CI logs a human can still trust before coffee. + +#### Supported reusable actions + +Devflare ships four reusable GitHub Actions for the repeatable parts. Use them directly rather than cloning shell logic into every workflow file. + +The action source lives in this repository, but the contract is meant to be reused: workspace setup, impact detection, deploy execution, and GitHub feedback are separate on purpose. + +##### Highlights + +- **devflare-setup-workspace** โ€” Install Bun, restore the Bun cache, and run one shared workspace install for the job. ([link](https://github.com/Refzlund/devflare/blob/next/.github/actions/devflare-setup-workspace/action.yml)) +- **devflare-deploy-impact** โ€” Decide whether one target package actually needs a deploy before Cloudflare work starts. ([link](https://github.com/Refzlund/devflare/blob/next/.github/actions/devflare-deploy-impact/action.yml)) +- **devflare-deploy** โ€” Run one explicit production or named preview-scope deploy and expose outputs for later verification. ([link](https://github.com/Refzlund/devflare/blob/next/.github/actions/devflare-deploy/action.yml)) +- **devflare-github-feedback** โ€” Publish PR comments, GitHub deployments, or both without mixing reporting into deploy execution. ([link](https://github.com/Refzlund/devflare/blob/next/.github/actions/devflare-github-feedback/action.yml)) + +#### `devflare-setup-workspace` + +Use `devflare-setup-workspace` once near the start of a job when later steps share the same checkout and dependency install. It installs Bun, restores the Bun cache, and runs the workspace install command from the chosen directory. + +This action is intentionally target-agnostic. It prepares the workspace; it never decides what to deploy. + +##### Key points + +- Best fit: one job that deploys more than one package or deploys and then runs follow-up verification. +- In a monorepo, keep `working-directory: .` so package deploy steps can reuse the root install. +- Later `devflare-deploy` steps should set `skip-setup: 'true'` and `skip-install: 'true'` after shared setup already ran. +- If you only have one simple deploy step, you can let `devflare-deploy` handle setup itself instead. + +> **Note โ€” Use it when the job has shared setup work** +> +> This action exists so Bun setup and dependency installation stay boring. That is a compliment. + +##### Example โ€” Prepare the workspace once + +###### File โ€” .github/workflows/preview.yml + +```yaml +- uses: Refzlund/devflare/.github/actions/devflare-setup-workspace@next + with: + working-directory: . +``` + +#### `devflare-deploy-impact` + +Use `devflare-deploy-impact` before any Cloudflare work. It compares the target package against the relevant git range and tells the workflow whether a deploy is actually needed. + +Call it once per deployment target. In multi-package preview families, that means one impact decision per worker or app, not one giant yes-or-no for the whole job. + +##### Key points + +- Run it before deploys, not after โ€” skipping a no-op deploy is the whole point. +- Pass event metadata from GitHub instead of guessing at comparison refs in shell. +- Keep one impact decision per target so the workflow can skip or deploy packages independently. +- Promote the `reason` output into human-readable feedback. It makes skipped runs much easier to trust. + +##### Reference table + +| Key field | Why it matters | +| --- | --- | +| `target-package` | Selects the workspace package whose changes should trigger a deploy. | +| `extra-paths` | Lets shared files outside the package root invalidate that target too. | +| `should-deploy` | The boolean gate your workflow should use before any deploy step runs. | +| `reason` | Short explanation you can surface in summaries, PR comments, and logs. | +| `changed-files` | Audit trail for what the comparison actually saw. | + +> **Note โ€” Use this to skip the boring non-events** +> +> No-op deploys still cost time, secrets exposure, and reviewer attention. This action exists to spend less of all three. + +##### Example โ€” Gate the deploy before Cloudflare work starts + +###### File โ€” .github/workflows/preview.yml + +```yaml +- name: Resolve documentation preview impact + id: impact + uses: Refzlund/devflare/.github/actions/devflare-deploy-impact@next + with: + target-package: documentation + default-branch: \${{ github.event.repository.default_branch }} + event-name: \${{ github.event_name }} + event-action: \${{ github.event.action || '' }} + push-before: \${{ github.event.before || '' }} + pull-request-base-sha: \${{ github.event.pull_request.base.sha || '' }} + pull-request-head-sha: \${{ github.event.pull_request.head.sha || '' }} +``` + +#### `devflare-deploy` + +Use `devflare-deploy` for the actual Devflare deploy step. It can prepare Bun and dependencies for a standalone job, or it can reuse shared setup from an earlier `devflare-setup-workspace` step. + +The action requires one explicit target. Use `production: 'true'` for `--prod`, or `preview-scope: ` for `--preview `. `working-directory` selects which package-local `devflare.config.ts` and scripts are in play. + +Its outputs are the hand-off point for the rest of the workflow: `preview-url`, `version-id`, `verification-note`, `status`, `failure-stage`, `exit-code`, and `log-excerpt` are all meant for later verification and GitHub feedback. + +##### Key points + +- Production is the supported lane for strict control-plane verification. Leave `verify-deployment` at its default `true`, and enable `require-fresh-production-deployment: 'true'` when you need a hard failure if Cloudflare keeps the old live deployment. +- Preview workflows in this repository use named preview scopes and then perform app-level verification after the deploy step. That is the supported preview posture here. +- Use `deploy-command` when the package already wraps Devflare behind `bun run deploy --` or another package-local script. +- Use `install-working-directory` to reuse a workspace-root install while still deploying from a package subdirectory. +- Pass `deploy-message` and `deploy-tag` when you want workflow runs to map cleanly onto Cloudflare version history. + +##### Reference table + +| Input or output | Role | +| --- | --- | +| `working-directory` | Selects the package-local Devflare config and scripts. | +| `production` | Requests an explicit `--prod` deployment. | +| `preview-scope` | Requests an explicit named preview deployment via `--preview `. | +| `verify-deployment` | Controls whether the action enforces Cloudflare control-plane verification. | +| `require-fresh-production-deployment` | Tightens production verification when a new live deployment must be visible. | +| `preview-url`, `version-id`, `verification-note`, `status` | Outputs the rest of the workflow should consume for verification and feedback. | + +> **Warning โ€” Choose exactly one target** +> +> The action intentionally rejects ambiguous callers. If a workflow cannot tell whether it is preview or production, the logs will not be much comfort later either. + +> **Note โ€” Preview verification is different from production verification** +> +> Preview jobs still need real post-deploy checks for the application they expose. In this repository that means URL and content verification for documentation previews plus deployed-binding verification for the testing preview family. + +##### Example โ€” Named preview deploy + +###### File โ€” .github/workflows/preview.yml + +```yaml +- id: pr-deploy + uses: Refzlund/devflare/.github/actions/devflare-deploy@next + with: + working-directory: apps/documentation + install-working-directory: . + skip-setup: 'true' + skip-install: 'true' + deploy-command: bun run deploy -- + preview-scope: \${{ needs.resolve-context.outputs.pr-preview-scope }} + verify-deployment: 'false' + deploy-message: Documentation PR preview \${{ github.sha }} (run \${{ github.run_id }}) + deploy-tag: documentation-pr-preview-\${{ github.run_id }} + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +``` + +##### Example โ€” Explicit production deploy + +###### File โ€” .github/workflows/production.yml + +```yaml +- id: deploy + uses: Refzlund/devflare/.github/actions/devflare-deploy@next + with: + working-directory: apps/documentation + install-working-directory: . + deploy-command: bun run deploy -- + production: 'true' + deploy-message: Documentation production \${{ github.sha }} (run \${{ github.run_id }}) + deploy-tag: documentation-production-\${{ github.run_id }} + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +``` + +#### `devflare-github-feedback` + +Use `devflare-github-feedback` to publish the result after deploy and verification have already been decided. It can update a PR comment, a GitHub deployment record, or both. + +Keeping feedback separate from deploy execution matters. You can retry reporting, mark cleanup inactive, or change comment grouping without touching the Cloudflare deploy mechanics. + +##### Key points + +- Use `mode: deployment` for branch previews and production lanes that should show up in the GitHub Deployments UI. +- Use `mode: comment` for PR previews and group multiple sections into one stable comment with `comment-key` plus `comment-section-key`. +- Use `operation: cleanup` and `status: inactive` after preview cleanup so GitHub stops pretending old previews are still alive. +- Surface `summary`, `details-markdown`, and log links so reviewers do not have to spelunk raw job output. + +##### Reference table + +| Field | Use it for | +| --- | --- | +| `mode` | Choose PR comments, GitHub deployments, or both. | +| `operation` | Differentiate normal reporting from cleanup or inactive updates. | +| `status` | Publish `success`, `failure`, `skipped`, `in_progress`, or `inactive`. | +| `comment-key` and `comment-section-key` | Keep one durable PR comment and merge multiple preview sections into it. | +| `environment` and `environment-url` | Populate the GitHub Deployments UI with the right environment identity. | +| `log-url` and `log-excerpt` | Make failure context readable without digging through raw workflow output. | + +##### Example โ€” Publish grouped PR feedback + +###### File โ€” .github/workflows/preview.yml + +```yaml +- uses: Refzlund/devflare/.github/actions/devflare-github-feedback@next + with: + github-token: \${{ github.token }} + mode: comment + operation: report + status: success + title: Documentation PR preview + comment-key: pr-deployment-status + comment-section-key: documentation-preview + pr-number: \${{ needs.resolve-context.outputs.pr-number }} + preview-url: \${{ steps.pr-deploy.outputs.preview-url }} + version-id: \${{ steps.pr-deploy.outputs.version-id }} + log-url: \${{ github.server_url }}/\${{ github.repository }}/actions/runs/\${{ github.run_id }} +``` + +#### Supported workflow files and deployment strategies + +The repository currently demonstrates three workflow files and six supported lane types. You do not need to collapse them into one mega-workflow to be โ€œofficialโ€; the official part is the clear contract between the workflow lane and the reusable actions. + +##### Highlights + +- **workspace-ci.yml** โ€” Validation-only lane for the monorepo. No Cloudflare target, no deploy side door. ([link](https://github.com/Refzlund/devflare/blob/next/.github/workflows/workspace-ci.yml)) +- **preview.yml** โ€” Shared preview lifecycle workflow for branch previews, PR previews, multi-package preview families, and cleanup. ([link](https://github.com/Refzlund/devflare/blob/next/.github/workflows/preview.yml)) +- **documentation-production.yml** โ€” Explicit production lane for the documentation app with live verification after deploy. ([link](https://github.com/Refzlund/devflare/blob/next/.github/workflows/documentation-production.yml)) + +##### Reference table + +| Strategy | Workflow file | Verification style | GitHub surface | +| --- | --- | --- | --- | +| Validation only | `workspace-ci.yml` | Workspace build, typecheck, and test validation. | None โ€” this lane does not deploy. | +| Branch preview | `preview.yml` | Target checks plus app-level verification after deploy. | GitHub deployment record. | +| Pull request preview | `preview.yml` | Target checks plus app-level verification after deploy. | Grouped PR comment. | +| Multi-package preview family | `preview.yml` | Per-package deploys plus family-level verification. | GitHub deployment record and grouped PR comment. | +| Production | `documentation-production.yml` | Deploy action control-plane checks plus live URL verification. | GitHub deployment record. | +| Cleanup | `preview.yml` | Successful cleanup command plus inactive feedback update. | Inactive deployment or PR comment section. | + +#### Validation strategy: `workspace-ci.yml` + +`workspace-ci.yml` is the validation lane. It restores Bun and Turborepo caches, installs once, and runs `bun run devflare:ci`. + +It intentionally does not choose a Cloudflare target or request Cloudflare secrets. That keeps repo-wide confidence separate from deploy intent. + +##### Key points + +- Trigger it on repo-wide changes that affect apps, cases, packages, or shared tooling. +- Use it to prove the monorepo still builds, types, and tests before package-specific deploy lanes matter. +- Treat it as a prerequisite lane, not a back door into deployment. + +> **Tip โ€” Validation stays validation** +> +> If a workflow validates the workspace, let it do that well. Sneaking deploy behavior into it is how release lanes get mysterious. + +#### Branch preview strategy + +Non-default branch pushes get a stable branch-named preview scope in `preview.yml`. The workflow resolves context once, sets up the workspace once, and then updates only the affected targets for that branch scope. + +This is the supported pattern when you want a shareable branch preview that survives multiple pushes and can also coexist with a PR-scoped preview. + +##### Highlights + +- **preview.yml** โ€” The shared preview workflow resolves context once and then updates branch-scoped targets separately from PR-scoped targets. ([link](https://github.com/Refzlund/devflare/blob/next/.github/workflows/preview.yml)) + +##### Key points + +- The preview scope is the source branch name. +- Run `devflare-deploy-impact` before each deploy target so unchanged packages skip Cloudflare work. +- Publish a GitHub deployment record for branch previews so reviewers can find the environment history. +- Follow the deploy with app-specific verification, not just โ€œthe command exitedโ€. + +#### Pull request preview strategy + +Pull requests targeting the default branch get a stable `pr-` preview scope in the same `preview.yml` workflow. The workflow can update the branch preview, the PR preview, or both from the same checkout when that branch already belongs to an open PR. + +PR preview reporting is grouped into one comment so documentation and testing results update in place instead of spraying the thread with duplicate status noise. + +##### Key points + +- Use `opened`, `reopened`, and `ready_for_review` to create or refresh the PR preview. +- Use `comment-key: pr-deployment-status` plus section keys to merge multiple preview lanes into one durable comment. +- If impact says `skip`, report `skipped` and leave the existing preview in place rather than tearing it down. +- Keep branch and PR deploy steps separate even when they share preparation work. They are different targets with different review questions. + +> **Note โ€” Stable PR scopes reduce churn** +> +> Updating `pr-` in place is much easier to review than minting a brand-new preview identity on every commit. + +#### Multi-package preview family strategy + +Some applications are really a family of workers. `apps/testing` is the reference pattern: auth service, search service, and main app deploy separately, but they share one preview scope and one workflow lane. + +This is the supported strategy when previews need stronger isolation than same-worker uploads can provide, or when bindings across multiple workers must resolve together. + +##### Highlights + +- **verify-testing-preview-deployment.ts** โ€” The testing preview family finishes with a purpose-built verification script that checks the deployed binding shape, not just deploy command exit codes. ([link](https://github.com/Refzlund/devflare/blob/next/.github/scripts/verify-testing-preview-deployment.ts)) + +##### Key points + +- Evaluate impact per worker or app package. +- Deploy each package with its own `working-directory` and the same `preview-scope`. +- Add one family-level verification step after the main deploy to confirm the deployed bindings and URLs line up. +- Publish both deployment records and grouped PR feedback from the same verified result. + +> **Warning โ€” This is the right instinct for DO-heavy or service-bound apps** +> +> When one preview really means several workers plus shared bindings, model that explicitly instead of pretending one same-worker upload tells the full truth. + +#### Production strategy + +`documentation-production.yml` is the reference production lane: resolve impact, perform one explicit production deploy, verify the live site, and then publish a GitHub deployment. + +This is the supported split for production automation: let the deploy action handle Cloudflare control-plane verification, then add one live check that proves the currently served app really matches the commit you just shipped. + +##### Highlights + +- **documentation-production.yml** โ€” The reference production workflow for a Devflare app: impact check, explicit production deploy, live verification, then GitHub deployment feedback. ([link](https://github.com/Refzlund/devflare/blob/next/.github/workflows/documentation-production.yml)) + +##### Key points + +- Run on default-branch pushes or manual dispatch. +- Use `production: 'true'` instead of inferring production from branch names inside shell logic. +- Keep `verify-deployment` enabled for production. +- Use the deploy output URL or the stable production URL for a live content check like `/build.json`. +- Publish the final environment URL and version ID back to GitHub. + +> **Tip โ€” Production gets the strictest verification** +> +> Production should fail when the control plane or the live URL cannot prove what is serving. Better a loud release lane than a confident fiction. + +#### Cleanup strategy + +Cleanup is a supported lifecycle lane, not an afterthought. `preview.yml` handles branch deletion, PR closure, and manual cleanup dispatches from the same policy surface as preview creation. + +Each cleanup job checks out the default branch, reinstalls the shared workspace, runs `devflare previews cleanup --scope --apply`, and then marks the matching GitHub deployment or PR comment section inactive. + +##### Key points + +- Use branch deletion or manual dispatch for branch-scoped cleanup. +- Use PR closure for PR-scoped cleanup. +- Keep the scope name identical to the deploy lane so cleanup is obvious and deterministic. +- Mark feedback inactive after infrastructure cleanup so GitHub reflects reality instead of wishful thinking. + +> **Important โ€” Cleanup is part of the contract** +> +> A preview strategy that never documents cleanup is just deferred archaeology. + +--- + +### Run deploy commands as explicit recipes with expected files and effects + +> Use build, dry-run, production deploy, named preview deploy, same-worker preview upload, cleanup, and GitHub Actions as separate recipes with visible effects. + +| Field | Value | +| --- | --- | +| Route | [`/docs/deploy-command-recipes`](/docs/deploy-command-recipes) | +| Group | Ship & operate | +| Navigation title | Deploy recipes | +| Eyebrow | Deploy | + +Deploy docs should start from commands a developer can copy and the artifacts or remote effects they should expect, then move caveats into boundary notes after the working recipe. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Shipping without guessing target or cleanup behavior | +| Local artifacts | `.devflare/**` and `.wrangler/deploy/**` | +| Remote effects | Only deploy commands with explicit targets touch Cloudflare | + +#### Command recipes + +##### Reference table + +| Task | Command | Expected result | +| --- | --- | --- | +| Build local artifacts | `bunx --bun devflare build --env production` | Writes deploy-ready generated output; does not touch Cloudflare. | +| Inspect compiled config | `bunx --bun devflare config print --format wrangler` | Prints Wrangler-facing config for review. | +| Dry-run production deploy | `bunx --bun devflare deploy --prod --dry-run` | Exercises deploy planning without uploading. | +| Production deploy | `bunx --bun devflare deploy --prod` | Uploads to the stable production Worker name. | +| Same-worker preview upload | `bunx --bun devflare deploy --preview` | Uses Cloudflare same-worker preview behavior and synthetic preview scope. | +| Named preview scope | `bunx --bun devflare deploy --preview pr-123` | Uses explicit preview scope for resource naming, logs, and cleanup. | +| Inspect preview bindings | `bunx --bun devflare previews bindings --scope pr-123` | Shows resolved preview resources and worker references. | +| Clean preview resources | `bunx --bun devflare previews cleanup --scope pr-123 --apply` | Deletes preview-owned resources and dedicated preview workers when applicable. | + +#### Same-worker preview vs named preview scope + +##### Reference table + +| Model | Use when | Tiny example | +| --- | --- | --- | +| Same-worker preview | You want Cloudflare preview upload behavior and do not need a human-named resource scope. | `devflare deploy --preview` | +| Named preview scope | You want logs, resource names, cleanup, and GitHub feedback tied to a visible name. | `devflare deploy --preview pr-123` | +| Branch-scoped worker family | Durable Objects, queues, crons, or service topology need stronger isolation. | `preview.scope()` plus dedicated preview worker naming | + +#### Preview resource lifecycle by feature + +##### Reference table + +| Feature | Lifecycle stance | +| --- | --- | +| KV, D1, R2, Queues, Vectorize | Can be preview-scoped and managed when authored with preview-aware names. | +| Services and Durable Objects | Worker naming and migrations require explicit preview strategy; cleanup can remove preview-only workers. | +| Analytics Engine and Browser Rendering | Reported as warnings because there is no ordinary account resource to delete. | +| Hyperdrive | Cleanup can remove existing preview configs, but database ownership stays product-owned. | +| AI, Images, Media, Containers | Product-owned remote behavior; use smoke tests and usage limits rather than pretending local cleanup owns the product. | + +#### Minimal GitHub Actions preview workflow + +##### Example โ€” Preview workflow + +###### File โ€” .github/workflows/preview.yml + +```yaml +name: Preview + +on: + pull_request: + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/devflare-setup-workspace + - uses: ./.github/actions/devflare-deploy + with: + working-directory: packages/app + target: preview + preview-scope: pr-${{ github.event.pull_request.number }} + - uses: ./.github/actions/devflare-github-feedback + with: + preview-scope: pr-${{ github.event.pull_request.number }} +``` + +--- + +### Explicit production deploys with inspectable output + +> Production uses `--prod` or `--production`, preview uses `--preview` or `--preview `. No target means no deploy. + +| Field | Value | +| --- | --- | +| Route | [`/docs/production-deploys`](/docs/production-deploys) | +| Group | Ship & operate | +| Navigation title | Production deploys | +| Eyebrow | Production | + +Devflare resolves config, generates Wrangler artifacts, and deploys against an explicit destination. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Production deploys and preflight checks | +| Required target | `--prod`, `--production`, `--preview`, or `--preview ` | +| Best debug habit | Inspect compiled output before deploying | + +#### The production lane + +Refresh generated types when bindings or entrypoints changed, build once, inspect when the setup changed, then deploy with an explicit production target. + +The CLI page owns the broad command map. This page covers how those commands fit the release lane. + +##### Steps + +1. Run `devflare types` when bindings or entrypoints changed and `env.d.ts` needs to catch up. +2. Run `devflare build --env production` to generate production artifacts. +3. Use `devflare config print --format wrangler` or `devflare doctor` when the compiled result needs inspection before release. +4. Run `devflare deploy --prod` or `--production` only when the target is unmistakably production. Add `--dry-run` first if you want to verify the pipeline without pushing. + +> **Note โ€” Need the full command map?** +> +> Open the CLI page when the question is what `types`, `build`, `config`, or `doctor` generally do. This page only covers how those commands fit the production release lane. + +##### Example โ€” Production release workflow with an explicit target + +Keep the same local release lane visible in CI: generate types, build production output, dry-run the deploy, then push only with `--prod`. + +###### File โ€” .github/workflows/production.yml + +```yaml +name: Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx --bun devflare types + - run: bunx --bun devflare build --env production + - run: bunx --bun devflare deploy --prod --dry-run + - run: bunx --bun devflare deploy --prod +``` + +#### Production deploys are explicit + +Deploy requires an explicit target so production and preview stay unmistakable. Production is `--prod` or `--production`; preview is `--preview` or `--preview `. + +Production deploys also clear preview-scope overrides like `DEVFLARE_PREVIEW_BRANCH` so stable worker names point at stable infrastructure. + +> **Warning โ€” No target means no deploy** +> +> Intentional. Keeps production vs. preview intent visible in CI logs and command history. + +> **Note โ€” Stricter verification in automation** +> +> The reusable deploy action exposes `verify-deployment` and `require-fresh-production-deployment` so CI can fail when Cloudflare cannot confirm the expected version. + +##### Example โ€” Production deploy commands + +```bash +bunx --bun devflare build --env production +bunx --bun devflare deploy --prod +bunx --bun devflare deploy --production --message "Release 1" --tag release-1 +``` + +#### Preflight tools + +##### Key points + +- `devflare deploy --prod --dry-run` โ€” run the full deploy pipeline without pushing anything to Cloudflare. +- `devflare config print --format wrangler` โ€” see the compiled deployment shape. +- `devflare doctor` โ€” check config resolution, Vite opt-in, and generated files. +- `devflare build` before deploy โ€” when the package just gained new bindings, routes, or framework wiring. + +--- + +### Turborepo validates the workspace, Devflare deploys the target package + +> Turbo owns task orchestration and caching. `devflare` still runs from the package that owns the Worker or app. + +| Field | Value | +| --- | --- | +| Route | [`/docs/monorepo-turborepo`](/docs/monorepo-turborepo) | +| Group | Ship & operate | +| Navigation title | Monorepos & Turborepo | +| Eyebrow | Monorepo | + +Turbo at the root, `devflare.config.ts` local to each deployable package. Turbo decides what to build; deploy commands run in the package that owns the config. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Bun + Turborepo monorepos with multiple Devflare packages | +| Turbo role | Validation, caching, filters, orchestration | +| Deploy rule | Run `devflare` from the package that owns the config | + +#### Keep the workspace boundary clear + +In a monorepo, Turbo and Devflare solve different problems. Turbo owns the workspace graph: cached builds, targeted checks, and โ€œwhat changed?โ€ filters. Devflare owns package-local Cloudflare behavior: config resolution, generated Wrangler output, preview logic, and production deploys. + +That means every deployable package should still keep its own `devflare.config.ts`, package scripts, and package-specific runtime assumptions. Turbo should orchestrate those packages, not erase their boundaries. + +##### Key points + +- Keep one `devflare.config.ts` per deployable package or worker family member. +- Use repo-root Turbo scripts for validation lanes and targeted build/check work. +- Use package-local `devflare` commands for actual build or deploy intent. +- Use GitHub workflow path filters or Turbo filters to decide whether a deploy job should run at all. + +#### Know which layer owns what + +##### Reference table + +| Layer | Owns | +| --- | --- | +| Turborepo | Task graph, caching, filters, workspace validation lanes, and targeted build/check/test/type flows. | +| Devflare | Config resolution, type generation, worker bundling, preview deploys, production deploys, and preview lifecycle commands. | +| GitHub Actions | Triggers, permissions, branch/PR policy, feedback, and the working directory that selects the target package. | + +> **Note โ€” Good default review question** +> +> Ask two separate questions: โ€œWhich packages should Turbo run?โ€ and โ€œWhich package is actually deploying?โ€ Conflating those is how monorepo deploy flows get muddy. + +#### Repo-root Turbo scripts for contributors and CI + +The repo exposes root scripts for the core Devflare workflow so contributors and CI can validate without guessing at filters. + +These are validation and orchestration tools, not a replacement for package-local deploy commands. + +##### Example โ€” Root scripts keep Turbo orchestration separate from package deploys + +Use root scripts for workspace validation and keep each app package responsible for the Devflare command that resolves its own config. + +###### File โ€” package.json + +```json +{ + "scripts": { + "devflare:build": "turbo run build --filter=devflare --filter=documentation", + "devflare:test": "turbo run test --filter=...devflare", + "devflare:ci": "bun run devflare:build && bun run devflare:test" + } +} +``` + +###### File โ€” turbo.json + +```json +{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".svelte-kit/**"] + }, + "test": { + "dependsOn": ["build"] + } + } +} +``` + +###### File โ€” apps/documentation/package.json + +```json +{ + "scripts": { + "deploy": "devflare deploy", + "deploy:preview": "devflare deploy --preview docs-preview", + "deploy:prod": "devflare deploy --prod" + } +} +``` + +##### Example โ€” Repo-root validation lane + +```bash +bun run devflare:build +bun run devflare:typecheck +bun run devflare:test +bun run devflare:types +bun run devflare:check +bun run devflare:ci +``` + +##### Example โ€” Targeted Turbo work from the repo root + +```bash +bun run turbo build --filter=documentation +bun run turbo check --filter=documentation +``` + +#### Deploy from the package that owns the config + +##### Steps + +1. Use Turbo or path-aware workflow logic to decide whether a package is affected. +2. Optionally run Turbo build/check work for that package from the repo root. +3. Run `devflare deploy ...` from the package directory that owns the `devflare.config.ts` you actually want to resolve. +4. Keep preview-vs-production intent explicit in the final package-local deploy command. + +> **Warning โ€” Keep package selection explicit** +> +> If the deploy is for `apps/documentation`, make that obvious in the working directory or script name. The package boundary should be visible in logs and workflow steps. + +##### Example โ€” Documentation app from a monorepo + +```bash +# optional repo-root validation +bun run turbo build --filter=documentation +bun run turbo check --filter=documentation + +# actual deploy from the app package +cd apps/documentation +bun run deploy -- --preview feature-search +bun run deploy -- --prod +``` + +#### Multi-worker previews deploy per-package + +`apps/testing` shows the other half: Turbo orchestrates the workspace, but a branch-scoped preview family still deploys each worker separately with the same preview scope. + +The workflows keep `DEVFLARE_PREVIEW_BRANCH` consistent and run separate deploys for `auth-service`, `search-service`, and the main app. + +##### Example โ€” Branch-scoped worker family deployment + +```bash +export DEVFLARE_PREVIEW_BRANCH='pr-123' +# PowerShell: $env:DEVFLARE_PREVIEW_BRANCH = 'pr-123' + +cd apps/testing/workers/auth-service +bunx --bun devflare deploy --preview pr-123 + +cd ../search-service +bunx --bun devflare deploy --preview pr-123 + +cd ../../ +bunx --bun devflare deploy --preview pr-123 +bunx --bun devflare previews cleanup --scope pr-123 --apply +``` + +--- + +### Pick the preview model that matches the app + +> Same-worker uploads, named preview scopes, and branch-scoped worker families serve different needs. + +| Field | Value | +| --- | --- | +| Route | [`/docs/preview-strategies`](/docs/preview-strategies) | +| Group | Ship & operate | +| Navigation title | Preview strategies | +| Eyebrow | Previews | + +Pick the right preview model before writing CI around assumptions the platform will not honor. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Choosing preview strategy before building CI | +| Same-worker mode | Plain `--preview` | +| Named scope mode | `--preview ` | + +#### More than one preview model + +Both targets resolve `config.env.preview` and can materialize `preview.scope()` names. Bare `--preview` uses the synthetic `preview` identifier; `--preview ` swaps it for an explicit scope that pairs with branch-scoped preview workers. + +Plain `--preview` can still receive `--branch-name` or CI metadata for logs, but preview-scoped resource names use the synthetic identifier unless you pick an explicit scope. + +When you need stronger isolation or cleaner cleanup, prefer named scopes directly. + +##### Reference table + +| Preview style | Use it when | +| --- | --- | +| Plain `--preview` | You want a same-worker preview upload and the synthetic `preview` identifier is enough for any `preview.scope()` resource names. | +| Named `--preview ` | You need an explicit preview identifier for resource names or branch-scoped preview workers. | +| Branch-scoped worker family | The app is Durable Object-heavy or otherwise needs stronger isolation than same-worker preview uploads can provide. | + +#### Cloudflare caveats still matter + +##### Key points + +- Preview URLs must be enabled for the worker or the returned links may not be usable. +- Preview URLs are public unless you protect them with Cloudflare Access or another layer. +- Plain `--preview` cannot be the first-ever upload path for a brand-new worker. +- Cloudflare does not currently generate preview URLs for workers that implement Durable Objects. +- `wrangler versions upload` does not currently apply Durable Object migrations. +- Same-worker preview uploads are also the wrong fit when branch isolation must cover cron or queue topology, not just the request path. + +> **Warning โ€” DO-heavy apps need a different preview instinct** +> +> If previews must exercise real Durable Object behavior, use branch-scoped worker families and preview-scoped resources. + +#### Preview-scoped resources + +Branch-scoped previews sometimes need their own KV, D1, R2, Queue, or Vectorize resources. `preview.scope()` keeps authored config stable while preview environments resolve preview-specific names. + +Outside preview, those markers resolve back to the base names. Inside preview, bare `--preview` materializes names like `my-cache-kv-preview`; `--preview next` materializes `my-cache-kv-next`. + +##### Example โ€” Preview-scoped resource naming + +```ts +import { defineConfig, preview } from 'devflare/config' + +const pv = preview.scope() + +export default defineConfig({ + bindings: { + kv: { + CACHE: pv('my-cache-kv') + }, + r2: { + ASSETS: pv('my-assets-bucket') + } + } +}) +``` + +--- + +### Use the operator command families for account context, live production changes, renames, token bootstrap, and paid-test gates + +> Devflareโ€™s deeper CLI families exist so account selection, live production inspection, Worker renames, token lifecycle, and remote paid-test gates stay documented instead of dissolving into ad-hoc command snippets. + +| Field | Value | +| --- | --- | +| Route | [`/docs/control-plane-operations`](/docs/control-plane-operations) | +| Group | Ship & operate | +| Navigation title | Control-plane operations | +| Eyebrow | Operations | + +The root CLI page maps these command families, but once you start operating real Cloudflare state, the important questions change. Which account is this command acting on? Is this a read-only production inspection or a dry-run rollback? Does this rename update the local config too? Should remote paid tests be enabled at all? This page keeps those answers in one place. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Teams operating live accounts, releases, and paid test flows instead of only building locally | +| Read-only production view | `devflare productions` and `devflare productions versions` | +| Mutation safety habit | Prefer dry runs first, then add `--apply` only when the target is obvious | +| Paid-test gate | `devflare remote status\|enable\|disable` plus `DEVFLARE_REMOTE` awareness | + +#### Choose account context before you operate on anything important + +The safest operational habit in Devflare is to resolve account context first. The CLI can infer an account from several places, but when real inventory, preview cleanup, token management, or production control-plane changes are involved, you should know which lane won. + +Not every command family resolves those lanes in the same order. Inventory-oriented commands, `productions` discovery, other config-backed operator commands, and token management each consult a slightly different subset of explicit flags, workspace settings, environment, config, and authenticated-account fallbacks. + +`login`, `account`, and the global or workspace account selectors exist for this reason. They make the account story explicit before the deeper command families start reading or mutating Cloudflare state. + +##### Reference table + +| Command family | How account choice resolves | Practical habit | +| --- | --- | --- | +| `devflare account ...` | `--account` wins, then workspace account selection, `CLOUDFLARE_ACCOUNT_ID`, resolved config `accountId`, and finally the primary authenticated account. | Great for inventory, but still pass `--account` when a read or write must be unmistakable. | +| `devflare productions ...` | `--account` wins. Otherwise Devflare may scan local configs for primary workers, stop with an explicit error if that scan finds more than one configured `accountId`, and only then fall back to the narrower production account-resolution path. | In a monorepo or mixed-account tree, pass `--account` instead of asking productions to guess. | +| Other config-backed families such as `previews` and `worker rename` | Explicit `--account` wins; otherwise Devflare can use resolved config `accountId` or later fall back to effective-account preferences and the authenticated account. | Set `accountId` in package config when that package genuinely belongs to one account. | +| `devflare tokens ...` | Uses `--account` first, then workspace account selection, then the primary account visible to the bootstrap token. | Treat token management as its own lane and make the target account obvious in logs. | + +> **Note โ€” Interactive account selection is a real workflow, not just a convenience extra** +> +> `devflare account global` and `devflare account workspace` exist so repeated operational commands can stay honest without pasting account ids into every invocation. +> +> The workspace preference lives with the workspace metadata, while the global default is cached locally and mirrored best-effort to Devflare-managed Cloudflare state when you are authenticated. +> +> Some command families consult those effective-account preferences directly, while others read a narrower lane first. That difference is why the docs call out the command family instead of pretending there is one universal resolution order. +> +> `devflare productions` is the strictest example here: if local config discovery turns up multiple configured account ids, it refuses to guess and asks for `--account`. + +##### Example โ€” Fail an operator script when the expected account is not active + +Use the same account helpers as the CLI when automation needs a hard preflight instead of a human-readable inventory page. + +###### File โ€” scripts/assert-account.ts + +```ts +import { account } from 'devflare/cloudflare' + +const expectedAccountId = process.env.CLOUDFLARE_ACCOUNT_ID + +if (!expectedAccountId) { + throw new Error('Set CLOUDFLARE_ACCOUNT_ID before running operator automation') +} + +const primary = await account.getPrimaryAccount() + +if (primary?.id !== expectedAccountId) { + throw new Error('Expected Cloudflare account ' + expectedAccountId + ', got ' + (primary?.id ?? 'none')) +} + +const workers = await account.workers(expectedAccountId) +console.log('Operating on ' + workers.length + ' workers in ' + expectedAccountId) +``` + +##### Example โ€” Get the account context visible first + +```bash +bunx --bun devflare login +bunx --bun devflare account +bunx --bun devflare account workspace +bunx --bun devflare account workers +``` + +#### Treat usage and limits as Devflare-managed guardrails, not Cloudflare billing dashboards + +`devflare account usage` and `devflare account limits` expose the counters and ceilings Devflare uses for its own safety decisions. They are useful operator data, but they are not a full Cloudflare billing or quota dashboard. + +Today that mostly means AI request counts, Vectorize operation counts, and related limits that help Devflare decide when remote or preview-heavy workflows should stay deliberate instead of accidental. + +##### Key points + +- Use these commands as guardrails for Devflare-managed flows, not as the final source of truth for account billing. +- If you need official product usage or invoice-level numbers, keep Cloudflareโ€™s own dashboards and docs in the loop. +- Some limits are stored for future enforcement or reporting before every one of them becomes an active hard stop. + +> **Note โ€” Operationally useful, intentionally narrower than billing** +> +> These numbers are here to help Devflare behave safely. They should inform operator decisions, but they are not a substitute for Cloudflareโ€™s own product-level accounting. + +#### Inspect and change live production deliberately + +`devflare productions` is the control-plane surface for live production state. It reads Cloudflare deployment data directly, lists current Workers and stored versions, and only mutates production when you move from the read-only views into `rollback` or `delete`. + +That split matters because production inspection and production mutation are not the same job. Keep `versions` nearby when you need context, keep dry runs as the default posture, and add `--apply` only when you are already confident about the target. + +##### Reference table + +| Command | What it is for | Safety rule | +| --- | --- | --- | +| `devflare productions` | Inspect live production Workers and the active deployment shape. | Read-only by default. | +| `devflare productions versions` | Inspect recent stored production versions and see which version is active. | Read-only by default. | +| `devflare productions rollback` | Create a fresh production deployment that points at a previous or specific version. | Dry run unless you add `--apply`. | +| `devflare productions delete` | Delete one live production Worker script. | Dry run unless you add `--apply`, and it does not delete independent account resources automatically. | + +> **Note โ€” Production versions are a focused view, not the entire deployment history** +> +> `devflare productions versions` focuses on the recent non-preview versions that matter operationally, and the latest production deployment can still reference more than one active version when Cloudflare is splitting traffic. + +> **Warning โ€” Production deletion is intentionally narrow** +> +> `devflare productions delete` removes the Worker script only. Review KV, D1, R2, queues, and other account resources separately instead of assuming the control plane will clean them up for you. + +#### Use documented commands for renames, token bootstrap, and pricing context + +##### Highlights + +- **`worker rename`** โ€” Renames the remote Worker when needed, updates the matching local config name when it can resolve that config safely, warns about remaining local references, and may leave existing preview URLs showing the old worker name until fresh preview uploads exist. +- **`tokens`** โ€” Creates, rolls, lists, and deletes Devflare-managed account-owned API tokens using a bootstrap token that already has token-management permissions. Created tokens include the selected account and all zones in that account so deploys can manage Worker route and custom-domain state. Cloudflare returns token secrets only once, so the first output matters. +- **`ai`** โ€” Prints the built-in Workers AI pricing snapshot bundled with the current Devflare build. It is a reference command, not a live account-state query, so confirm current rates in Cloudflare docs when the numbers matter. + +##### Key points + +- Prefer `worker rename` over hand-editing config names and remote Worker names separately. +- Keep bootstrap tokens out of transcripts and remember that returned managed-token secrets are a one-time output. +- Use the built-in AI pricing command when the question is cost reference, not model invocation. + +##### Example โ€” Keep these control-plane jobs explicit too + +```bash +bunx --bun devflare worker rename docs --to devflare-docs +bunx --bun devflare tokens $BOOTSTRAP --list +bunx --bun devflare tokens $BOOTSTRAP --new preview +bunx --bun devflare ai +``` + +#### Gate paid remote test flows explicitly + +Remote mode exists so paid Cloudflare features like AI or Vectorize do not get exercised casually by every local or CI run. The command family is deliberately small: inspect current status, enable it for a bounded window, or disable it again. + +That keeps the cost story visible. If remote tests are going to hit real infrastructure, the activation should be reviewable in command history or workflow logs instead of quietly implied. + +##### Key points + +- The default `remote` action is `status`, so the current gate is easy to inspect before you run a paid test suite. +- `enable` defaults to 30 minutes when you do not pass a valid duration. +- `DEVFLARE_REMOTE` can keep effective remote mode active even after you run `disable`, so environment context still matters. + +> **Warning โ€” Remote mode is a cost gate, not a convenience toggle** +> +> Remote tests hit real Cloudflare services. Use the shortest useful enable window and keep the activation visible in automation when cost or quotas matter. + +##### Example โ€” Make remote mode a deliberate choice + +```bash +bunx --bun devflare remote status +bunx --bun devflare remote enable 30 +bunx --bun devflare remote disable +``` + +#### Use the neighboring docs when the job becomes preview lifecycle or CI policy + +##### Highlights + +- **devflare/cloudflare** โ€” Open the library API page when a script or tool should use the same auth, inventory, registry, usage, or token helpers that the CLI command families use internally. ([link](/docs/cloudflare-api)) +- **Preview operations** โ€” Open the preview lifecycle page when the job is inspection or resource cleanup for preview scopes. ([link](/docs/preview-operations)) +- **GitHub workflows** โ€” Open the workflow page when those operator commands need to become reviewable CI jobs with feedback, cleanup, and permissions. ([link](/docs/github-workflows)) +- **Production deploys** โ€” Open the production deploy page when the question is the deploy target itself rather than the later control-plane inspection or rollback flow. ([link](/docs/production-deploys)) + +--- + +### Use `devflare/cloudflare` when scripts should reuse Devflareโ€™s account, registry, and token helpers instead of reimplementing them + +> The `devflare/cloudflare` subpath exposes the same account-aware building blocks the CLI uses for auth, resource inventory, usage and limits, preview registry access, preferences, and managed token workflows. + +| Field | Value | +| --- | --- | +| Route | [`/docs/cloudflare-api`](/docs/cloudflare-api) | +| Group | Ship & operate | +| Navigation title | devflare/cloudflare | +| Eyebrow | Library API | + +This page is for Node-side scripts and tooling, not Worker runtime code. Reach for it when a release script, operator utility, or migration helper should reuse Devflareโ€™s Cloudflare-side knowledge instead of rebuilding auth, pagination, account selection, or preview-registry calls from scratch. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Import path | `devflare/cloudflare` | +| Primary surface | A flat `account` object plus standalone preview-registry helpers and schema exports | +| Best for | Release scripts, operator tooling, and Node-side automation that should reuse Devflareโ€™s Cloudflare-side rules | + +#### Use the library when your script needs Devflareโ€™s control-plane knowledge, not just a shell command + +Reach for `devflare/cloudflare` when a script should authenticate once, resolve an account deliberately, inspect resources, or talk to the preview registry using the same rules Devflare already ships. + +If the job is already well-served by `devflare account`, `devflare previews`, or another CLI command and the main need is a readable operator workflow, the CLI is usually simpler. The library is for composition. + +##### Highlights + +- **Good fit** โ€” A release script, CI helper, or internal ops tool needs account auth, inventory queries, preview registry reads, or token management as reusable functions. +- **Usually not the first fit** โ€” A human just needs to inspect state once. That is what the CLI pages and built-in help are already for. + +#### Know the main clusters on the public surface + +##### Reference table + +| Cluster | What it helps with | Examples | +| --- | --- | --- | +| Auth and account identity | Check auth, inspect accounts, and resolve the account you should operate on. | `account.isAuthenticated()`, `account.getAccounts()`, `account.getPrimaryAccount()` | +| Resource inventory | List Workers, D1 databases, KV namespaces, R2 buckets, Vectorize indexes, and related account resources. | `account.workers(accountId)`, `account.d1(accountId)`, `account.r2(accountId)` | +| Usage and limits | Read Devflare-managed operational counters and ceilings that inform remote or preview-heavy workflows. | `account.getUsageSummary(accountId, "ai")`, `account.getLimits(accountId)` | +| Preferences and defaults | Read or update Devflareโ€™s stored global or workspace account preferences. | `account.getGlobalDefaultAccountId(primaryId)`, `account.setWorkspaceAccountId(accountId)`, `account.getEffectiveAccountId(primaryId)` | +| Managed tokens and preview registry | Create or rotate Devflare-managed API tokens, and inspect or update preview-registry records with shared schemas. | `account.listAccountOwnedAPITokens(accountId)`, `account.ensurePreviewRegistry({ ... })`, `devflarePreviewRecordSchema` | + +> **Note โ€” This is the same mental model as the CLI, just as functions** +> +> If a CLI page talks about account preferences, preview registry records, or managed tokens, this subpath is usually where the reusable implementation lives. + +#### A small script can reuse auth and inventory without rebuilding them + +##### Key points + +- Keep account choice explicit in scripts that can touch more than one account. +- Reuse the exported helpers instead of hand-rolling Cloudflare REST calls unless you genuinely need an unsupported endpoint. +- Prefer returning structured data from your own scripts and let the CLI own human-readable operator output. + +##### Example โ€” List Workers for the primary account + +###### File โ€” scripts/list-workers.ts + +```ts +import { account } from 'devflare/cloudflare' + +const authenticated = await account.isAuthenticated() + +if (!authenticated) { + throw new Error('Run devflare login before using this script') +} + +const primary = await account.getPrimaryAccount() + + if (!primary) { + throw new Error('No Cloudflare account is available for this script') + } + + const workers = await account.workers(primary.id) + +for (const worker of workers) { + console.log(worker.name) +} +``` + +#### Preview registry helpers and schemas are public by design + +Devflare exports preview-registry helpers plus the shared registry schemas and errors so custom tooling can inspect or update preview metadata without guessing the record shape. + +That is especially useful for automation that wants to inspect preview URLs, scope metadata, or cleanup state while staying aligned with the same contract the CLI and GitHub actions use. + +##### Key points + +- Use schema exports such as `devflarePreviewRecordSchema` when you need to validate preview-registry data in your own tooling. +- Use `account.ensurePreviewRegistry(...)`, `account.listTrackedPreviewRecords(...)`, or the standalone preview-registry exports when you want the same storage contract the CLI already understands. +- Keep custom preview automation aligned with the docs on preview lifecycle instead of inventing parallel record shapes. + +#### Open the neighboring page when the question is policy or workflow, not raw API reuse + +##### Highlights + +- **Control-plane operations** โ€” Go back to the CLI-oriented page when the question is operator workflow, dry-run safety, rollback posture, or command-family behavior. ([link](/docs/control-plane-operations)) +- **Preview operations** โ€” Open the preview lifecycle page when your tool needs the broader policy around preview inspection and cleanup flows. ([link](/docs/preview-operations)) +- **GitHub workflows** โ€” Open the workflow page when your automation question is really about CI structure, action outputs, or PR feedback instead of raw Cloudflare helpers. ([link](/docs/github-workflows)) + +--- + +### Inspect and clean up previews + +> The preview registry is D1-backed, giving Devflare durable records of scope and deployment state for reliable cleanup. + +| Field | Value | +| --- | --- | +| Route | [`/docs/preview-operations`](/docs/preview-operations) | +| Group | Ship & operate | +| Navigation title | Preview operations | +| Eyebrow | Preview lifecycle | + +Preview commands are the public surface for understanding what exists and tearing down preview-only resources. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Preview lifecycle management | +| Registry backing | D1 (`devflare-registry` by default) | +| Cleanup warning | Dedicated preview workers may own more than just the script | + +#### Why the registry exists + +Cloudflare discovery alone is not enough for clean preview lifecycle management. The D1-backed registry tracks scope and deployment records for reliable inspection and cleanup. + +Devflare creates and updates the registry as preview deploys happen, so `previews` and `cleanup` work from real state. + +#### Core commands + +##### Key points + +- `previews` โ€” summary view of preview scopes. +- `bindings --scope ` โ€” which workers reference one named scope. +- Prefer explicit scope selectors when you know the target; reserve broad cleanup for when the whole fleet needs attention. +- Without `--scope`, `cleanup` respects `DEVFLARE_PREVIEW_IDENTIFIER`, `DEVFLARE_PREVIEW_PR`, or `DEVFLARE_PREVIEW_BRANCH`, then falls back to the synthetic `preview` scope. Use `--all` for every discovered scope. + +##### Example โ€” PR-close cleanup job for a named preview scope + +Turn the same cleanup command into reviewable automation so closed PR previews do not rely on memory. + +###### File โ€” .github/workflows/preview-cleanup.yml + +```yaml +name: Preview cleanup + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bunx --bun devflare previews bindings --scope pr-${{ github.event.pull_request.number }} + - run: bunx --bun devflare previews cleanup --scope pr-${{ github.event.pull_request.number }} --apply +``` + +##### Example โ€” Preview lifecycle commands + +```bash +bunx --bun devflare previews +bunx --bun devflare previews bindings --scope next +bunx --bun devflare previews cleanup --scope next --apply +bunx --bun devflare previews cleanup --all --apply +``` + +#### Cleanup should be specific + +##### Key points + +- Without `--apply`, cleanup runs as a dry run โ€” showing what would be removed without touching anything. +- With `--apply`, it deletes preview-only resources and can delete dedicated preview worker scripts. +- Stable shared workers are not deleted; same-worker uploads only lose matching preview-scoped resources. +- Analytics Engine datasets and Browser Rendering bindings are reported as warnings. Hyperdrive cleanup only removes configs that already exist. + +> **Important โ€” Good cleanup hygiene** +> +> Use the most specific selector you can. Cleanup is easier to trust when the target is obvious. + +> **Warning โ€” Not every preview-looking thing is deletable** +> +> Browser Rendering has no account-scoped resource, Analytics Engine datasets are created on first write, and Hyperdrive cleanup can only remove existing preview configs. The command tells you. + +--- + +### Test the runtime shape you ship, keep automation thin + +> Local harness detail stays on the testing pages. This page covers what gets promoted into CI and how automation stays observable. + +| Field | Value | +| --- | --- | +| Route | [`/docs/testing-and-automation`](/docs/testing-and-automation) | +| Group | Ship & operate | +| Navigation title | Testing & automation | +| Eyebrow | Validation | + +The local harness pages own `createTestContext()` and binding nuance. This page owns which checks move into preview validation and release automation. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | CI testing policy and preview validation | +| Local harness owner | `/docs/create-test-context` plus binding guides | +| Important nuance | `cf.worker.fetch()` is not a full `waitUntil()` drain | +| Workflow companion | `/docs/github-workflows` | + +#### Let the local testing pages own local harness detail + +This page used to repeat too much of the local harness story. The better split is simpler: keep `createTestContext()` behavior, autodiscovery, and binding-specific harness detail on the dedicated testing pages, then use this page for the question โ€œwhat should actually run in automation?โ€ + +That keeps local test design and CI policy from drifting into two slightly different copies of the same documentation. + +##### Highlights + +- **Testing overview** โ€” Use the map page first when you need to choose between starter tests, the harness page, binding-specific guides, runtime context, or CI-facing validation. ([link](/docs/testing-overview)) +- **createTestContext()** โ€” This is the canonical page for autodiscovery, helper timing, transport-aware round-trips, and the real `cf.*` helper behavior. ([link](/docs/create-test-context)) +- **Binding testing guides** โ€” Open these when the binding changes the honest testing posture and the local harness rules are no longer one-size-fits-all. ([link](/docs/binding-testing-guides)) + +> **Note โ€” Cleaner split keeps both pages better** +> +> Harness pages own local helper behavior. This page owns what gets promoted and how automation stays readable. + +#### Timing rules that matter in CI + +Automation does not need the full harness manual, but it needs the timing rules that produce flaky checks or false confidence. + +Promote the check that matches the behavior you need to trust. + +##### Reference table + +| When the check depends on... | Prefer | Why | +| --- | --- | --- | +| `waitUntil()` side effects from an HTTP handler | Assert the side effect directly or move to a higher-fidelity check. | `cf.worker.fetch()` returns when the handler resolves, not when every background task drains. | +| Queue, scheduled, or tail background work | `cf.queue.trigger()`, `cf.scheduled.trigger()`, or `cf.tail.trigger()` | Those helpers wait for their background work before they return, so they are a better fit for async side-effect assertions. | +| Binding-specific or transport-specific behavior | The binding guide or `create-test-context` page first | Different bindings and bridge-backed values have different honest harness rules, and the local testing pages already own those details. | + +> **Warning โ€” Wrong completion contract = flaky CI** +> +> If a test depends on `waitUntil()` effects being complete, a plain `cf.worker.fetch()` assertion may be too early. + +#### Promote the smallest useful checks + +##### Highlights + +- **Preview operations** โ€” Use the preview page when a runtime check depends on preview-scoped resources, scope inspection, or cleanup behavior. ([link](/docs/preview-operations)) +- **Production deploys** โ€” Use the production page when the check is really about the deploy target, compiled output, or preflight inspection before release. ([link](/docs/production-deploys)) +- **GitHub workflows** โ€” Use the workflow page when those promoted checks need to become reviewable Actions jobs with explicit triggers, permissions, and feedback. ([link](/docs/github-workflows)) + +##### Steps + +1. Prove the behavior locally with `createTestContext()` or the binding-specific guide first. +2. Choose one or two runtime-shaped smoke checks worth rerunning in CI because they protect the deploy boundary. +3. Use preview validation when routing, preview-owned resources, or branch-scoped behavior is the real risk instead of trying to force every concern through one unit-style check. +4. Publish one visible summary or feedback artifact so reviewers can tell what passed without spelunking through raw logs. + +#### Automation stays thin and observable + +Deploy logic and GitHub feedback are separate. Cloudflare state changes stay independent from PR comments, deployment records, or other reporting. + +Caller workflows own branch naming, permissions, and feedback decisions. Reusable actions focus on one deploy or one reporting job. + +##### Highlights + +- **GitHub workflows** โ€” The workflow page owns the supported GitHub Actions patterns for impact checks, reusable actions, preview lanes, production lanes, PR feedback, and cleanup. ([link](/docs/github-workflows)) + +##### Key points + +- One package, one target, one visible result per workflow lane. +- Split deploy from feedback so reporting can fail or retry independently. +- Prefer summaries, PR comments, or deployment records over raw logs. + +> **Note โ€” Thin workflows age better** +> +> When a release is stressful, a small workflow that says what it deploys and what it reports is easier to trust. + +##### Example โ€” Thin preview deploy step + +###### File โ€” .github/workflows/preview.yml + +```yaml +- id: deploy + uses: Refzlund/devflare/.github/actions/devflare-deploy@next + with: + working-directory: apps/documentation + deploy-command: bun run deploy -- + preview-scope: \${{ github.head_ref || github.ref_name }} + verify-deployment: 'false' + cloudflare-api-token: \${{ secrets.CLOUDFLARE_API_TOKEN }} + cloudflare-account-id: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +``` + +--- + +### Make documentation changes part of public API changes + +> Public exports, schema keys, compiler output, typegen, CLI commands, test helpers, and support stances should fail CI when the docs do not change with them. + +| Field | Value | +| --- | --- | +| Route | [`/docs/docs-release-gates`](/docs/docs-release-gates) | +| Group | Ship & operate | +| Navigation title | Docs release gates | +| Eyebrow | Verification | + +This is the maintainer checklist for keeping the docs from becoming a prose archive again. The tests cover drift; the manual QA checklist covers developer paths a test cannot fully feel. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Release and review checklists | +| Main command | `bun run devflare:docs-integrity` | +| Generated file | `packages/devflare/LLM.md` must match the docs model | + +#### Docs must change when these public surfaces change + +##### Reference table + +| Changed surface | Docs or test that must move | +| --- | --- | +| Public exports | Package entrypoint table and export drift test. | +| Config schema keys or binding compiler output | Binding guide manifest and schema coverage test. | +| Typegen output | Generated types docs and first binding examples. | +| CLI commands or help pages | CLI docs and command table drift test. | +| `devflare/test` helpers | `test-helper-reference` and helper coverage checks. | +| Cloudflare support stance | `feature-index`, binding pages, and support matrix snapshot. | +| Docs content model | Regenerate `packages/devflare/LLM.md` and pass generated handbook drift check. | + +#### Final manual QA checklist + +##### Key points + +- New user path: `first-worker` -> `first-unit-test` -> `first-route-tree` works as a narrative. +- Binding path: `first-bindings` -> one binding page -> matching testing guide. +- Test path: `test-helper-reference` names the smallest helper and cleanup pattern. +- Deploy path: `deploy-command-recipes` distinguishes build, dry-run, prod, preview, and cleanup. +- Remote-boundary path: `feature-index` and binding pages make auth, Docker/Podman, paid services, and skips explicit. + +##### Example โ€” Wire the docs gate into a CI job + +###### File โ€” .github/workflows/docs.yml + +```yaml +name: docs + +on: + pull_request: + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run --cwd apps/documentation check + - run: bun run devflare:docs-integrity +``` + +--- + +### Scan local, remote, test, preview, and docs support in one table + +> This page is the compact feature support index that keeps support level, Cloudflare boundary, test helper, preview lifecycle, and docs links in one place. + +| Field | Value | +| --- | --- | +| Route | [`/docs/feature-index`](/docs/feature-index) | +| Group | Guides | +| Navigation title | Feature index | +| Eyebrow | Support matrix | + +Use the feature index when you already know the feature name and need to decide whether the next proof belongs in pure unit tests, `createTestContext`, a Docker/Podman lane, or a Cloudflare-authenticated remote lane. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Support stance lookup | +| Snapshot source | `featureRows` in docs content | +| Remote rule | Remote-only behavior gets `shouldSkip.*` or a dedicated deploy smoke test | + +#### Feature support matrix + +##### Reference table + +| Feature | Support | Cloudflare boundary | Test helper | Preview lifecycle | Docs | +| --- | --- | --- | --- | --- | --- | +| Route tree | Full | No Cloudflare product boundary | `cf.worker` | N/A | /docs/first-route-tree | +| KV | Full | Account limits and deployed namespace state | `createTestContext`, `createOfflineEnv`, `createMockKV` | Managed when scoped | /docs/bindings/kv | +| D1 | Full | Account limits and deployed database state | `createTestContext`, `createOfflineEnv`, `createMockD1` | Managed when scoped | /docs/bindings/d1 | +| R2 | Full | Public delivery topology is Cloudflare-owned | `createTestContext`, `createOfflineEnv`, `createMockR2` | Managed when scoped | /docs/bindings/r2 | +| Durable Objects | Full | Migrations and placement are Cloudflare-owned | `createTestContext` | Branch-scoped isolation when needed | /docs/bindings/durable-objects | +| Queues | Full | Delivery and retry semantics are Cloudflare-owned | `cf.queue`, `createMockQueue` | Managed when scoped | /docs/bindings/queues | +| Scheduled | Full | Cron scheduling is Cloudflare-owned | `cf.scheduled` | Config-owned | /docs/create-test-context | +| Email | Full | Email Routing ingress remains Cloudflare-owned | `cf.email`, send-email binding tests | Address rules compile as authored | /docs/bindings/send-email | +| Tail Workers | Full | Live tail routing is Cloudflare-owned | `cf.tail` | Handler code only | /docs/create-test-context | +| Workers AI | Remote | Requires Cloudflare account | `shouldSkip.ai` | Product-owned | /docs/bindings/ai | +| Vectorize | Remote | Requires Cloudflare account | `shouldSkip.vectorize` | Managed when scoped | /docs/bindings/vectorize | +| Hyperdrive | Full | Hosted pooling, placement, credentials, and production routing are Cloudflare-owned | `createTestContext`, `createOfflineEnv` | Reuse or resolve when scoped | /docs/bindings/hyperdrive | +| Browser Rendering | Full | Hosted browser service fidelity is Cloudflare-owned | `createTestContext` or focused mocks | No account resource cleanup | /docs/bindings/browser-rendering | +| Worker Loaders | Full | Dynamic Worker upload and hosted lifecycle are Cloudflare-owned | `createTestContext`, `createMockWorkerLoader` | Config-owned | /docs/bindings/worker-loaders | +| Secrets Store | Full | Account secret provisioning and sync are Cloudflare-owned | `createOfflineEnv`, `createMockSecretsStoreSecret` | Product-owned | /docs/bindings/secrets-store | +| Workflows | Full | Deployed durability, retries, scheduling, and instance history are Cloudflare-owned | `createTestContext`, `createMockWorkflow` | Product-owned | /docs/bindings/workflows | +| Images | Full | Hosted storage, variants, delivery rules, billing, and final transform fidelity are Cloudflare-owned | `createTestContext`, `createMockImagesBinding` | Product-owned | /docs/bindings/images | +| Media Transformations | Full | Real codecs, output fidelity, cache behavior, and billing are Cloudflare-owned | `createTestContext`, `createMockMediaBinding` | Product-owned | /docs/bindings/media-transformations | +| Containers | Full | Cloudflare Containers deployment is remote | `containers`, `shouldSkip.containers` | Product-owned | /docs/bindings/containers | + +##### Example โ€” Use the matrix to pick a local proof lane + +###### File โ€” tests/cache.test.ts + +```ts +import { describe, expect, test } from 'bun:test' +import { createOfflineEnv } from 'devflare/test' + +describe('feature support matrix choice', () => { + test('KV can be proven with an offline binding fixture', async () => { + const env = createOfflineEnv({ + kv: ['CACHE'] + }) + + await env.CACHE.put('feature:homepage', 'enabled') + + expect(await env.CACHE.get('feature:homepage')).toBe('enabled') + }) +}) +``` + +--- + +### Choose the right storage binding first, then let the binding guides own the mechanics + +> Use this page to choose between KV, D1, R2, and Hyperdrive. Once the shape is clear, open the binding-specific guide for authoring, testing, and examples instead of reading several smaller pages that all repeat the same decision badly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/storage-bindings`](/docs/storage-bindings) | +| Group | Guides | +| Navigation title | Storage strategy | +| Eyebrow | Binding strategy | + +This is the storage chooser, not a second binding reference shelf. Use it when the question is โ€œwhich storage shape fits this worker?โ€ Then jump into the guide that owns the actual runtime, compile, testing, and preview details for that storage binding. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Choosing between KV, D1, R2, and Hyperdrive before you dive into one binding guide | +| Main question | Is the data keyed, query-shaped, object-shaped, or an existing remote database connection? | +| Safest default | Prefer stable names in config when the binding supports them | +| Open next | The specific binding guide once the storage shape is clear | + +#### Choose the storage shape before you choose the syntax + +The weirdest storage mistakes usually come from choosing by familiarity instead of by data shape. Devflare already has strong per-binding guides for authoring and testing, so this page should stay at the decision boundary instead of pretending to be four shorter reference pages glued together. + +Once the storage shape is obvious, the binding guide should take over. That keeps the library cleaner and makes the per-binding pages easier to trust. + +##### Reference table + +| Binding | Reach for it when | Usually the wrong fit | +| --- | --- | --- | +| `KV` | You need keyed lookups, cache-like state, feature flags, or lightweight session markers. | You need relational queries, joins, or object delivery. | +| `D1` | You need SQL, relations, filters, or schema-shaped data. | You only need key lookup or one blob of file data. | +| `R2` | You need objects, uploads, generated files, or browser-facing file delivery through a Worker. | You need query semantics or tiny cache records. | +| `Hyperdrive` | You already have a remote PostgreSQL system and the worker should reach it through Cloudflare acceleration. | A local-first or greenfield schema could live in D1 instead. | + +> **Note โ€” The page boundary is deliberate** +> +> This page should help you pick the binding. The actual binding guides should explain how to author it, test it, preview it, and ship it. + +#### Stable names are still the calmest authoring default + +Name-based storage bindings stay readable in source review and let Devflare resolve the noisy ids later when build, deploy, or config-print flows actually need them. + +That rule does not mean every binding works the same way, but it does keep the source-of-truth shape calmer for KV, D1, and Hyperdrive while R2 keeps its already-readable bucket names. + +##### Example โ€” Stable-name storage authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'storage-worker', + bindings: { + kv: { + CACHE: { name: 'cache-kv' } + }, + d1: { + DB: { name: 'app-db' }, + AUDIT: { id: 'existing-d1-id' } + }, + hyperdrive: { + POSTGRES: 'app-postgres' + }, + r2: { + ASSETS: 'app-assets' + } + } +}) +``` + +#### R2 still needs an explicit browser-delivery boundary + +Devflare gives you real R2 bindings in worker code and tests, but it does not promise a stable browser-facing local bucket URL contract. If the browser needs the file in local dev, route through the app instead of assuming the bucket origin is the interface. + +##### Highlights + +- **Public assets** โ€” Use a public bucket on a custom domain when anonymous reads are the product, not an accident. +- **Private assets** โ€” Keep the bucket private and serve through a Worker that owns auth, headers, and cache policy. +- **Direct uploads** โ€” Mint short-lived upload URLs from the backend and store object keys instead of pretending permanent raw URLs are the whole product. +- **R2 uploads & delivery** โ€” Open this when the real question is presigned uploads, public versus private delivery, Access protection, signed custom-domain media links, or the right dev-versus-production posture. ([link](/docs/r2-uploads-and-delivery)) + +##### Example โ€” Worker-gated file serving keeps the app boundary visible + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ env, params }: FetchEvent): Promise { + const object = await env.FILES.get(params.key) + if (!object) { + return new Response('Not Found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', + 'Cache-Control': 'private, max-age=0' + } + }) +} +``` + +#### Open the binding guide that owns the mechanics + +##### Highlights + +- **KV** โ€” Open the KV guide when the storage shape is keyed lookup, cache-like state, or namespace lifecycle. ([link](/docs/bindings/kv)) +- **D1** โ€” Open the D1 guide when the storage shape is query-driven and you need the actual SQL-shaped runtime contract. ([link](/docs/bindings/d1)) +- **R2** โ€” Open the R2 guide when the real question is bucket usage, testing, preview naming, or file delivery details. ([link](/docs/bindings/r2)) +- **Hyperdrive** โ€” Open the Hyperdrive guide when the worker is reaching an existing PostgreSQL system and the operational caveats matter more than the storage taxonomy. ([link](/docs/bindings/hyperdrive)) + +--- + +### Handle R2 uploads and file delivery explicitly instead of treating bucket URLs as the product + +> Use presigned `PUT` URLs for direct uploads, public buckets on custom domains for truly public assets, and private buckets plus Worker auth for protected files. Keep `r2.dev` out of production, and when a preview or environment needs its own bucket, scope it intentionally instead of borrowing production storage. + +| Field | Value | +| --- | --- | +| Route | [`/docs/r2-uploads-and-delivery`](/docs/r2-uploads-and-delivery) | +| Group | Guides | +| Navigation title | R2 uploads & delivery | +| Eyebrow | Guide | + +R2 itself is easy to bind. The hard part is the product boundary: should the browser upload directly, should reads stay behind your Worker, should teammates authenticate through Access, or should expiring custom-domain links be validated by a Worker or WAF rule? This page is the architecture guide for those choices. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Safest upload default | Presigned `PUT` URL plus browser-direct upload plus object key stored in your app database | +| Safest private delivery default | Private bucket plus Worker-gated reads | +| Do not ship this as prod delivery | `r2.dev` | +| Team-only fit | Custom domain plus Cloudflare Access | + +#### The fast rule set + +##### Key points + +- Use presigned `PUT` URLs for direct user uploads to R2. +- Use a public bucket on a custom domain for truly public assets. +- Use a private bucket plus Worker authorization for authenticated or tenant-scoped files. +- Use Cloudflare Access when the bucket should be visible only to teammates or your organization. +- Use a Worker-signed URL flow or WAF HMAC validation for expiring custom-domain media links. +- Do not use `r2.dev` for production delivery, and disable `r2.dev` if you protect a custom-domain bucket with Access or WAF so the bucket is not still public there. + +> **Important โ€” R2 binding mechanics are not the hard part** +> +> The architectural decision is whether the browser should talk to a signed upload URL, a public custom domain, or your own Worker route. That choice matters more than the one-line `bindings.r2` config. + +#### The usual safe upload flow is direct upload with a presigned `PUT` URL + +This is the usual safe default because large files do not have to stream through your app server or Worker just to end up in object storage anyway. + +Cloudflare's UGC guidance says the same thing: let the Worker control auth and upload intent, then let the client stream directly to R2. If you need post-upload workflows, R2 event notifications can push object-create events into Queues for moderation, metadata writes, or follow-up processing. + +##### Highlights + +- **Presigned URLs** โ€” Covers supported operations, security considerations, and the custom-domain limitation for presigned URLs. ([link](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)) +- **Configure CORS** โ€” Use this when browser uploads or downloads cross origins and you need the exact allowed origins, methods, and headers model. ([link](https://developers.cloudflare.com/r2/buckets/cors/)) +- **R2 event notifications** โ€” Use this when uploads should trigger queue-driven moderation, indexing, metadata writes, or other follow-up work. ([link](https://developers.cloudflare.com/r2/buckets/event-notifications/)) + +##### Key points + +- Generate object keys server-side, for example `users//.jpg`. +- Restrict `Content-Type` when signing uploads so mismatched uploads fail signature validation. +- Keep upload URLs short-lived and treat them as bearer tokens while they remain valid. +- Configure bucket CORS when the browser uploads directly. +- If uploads arrive from many regions, Local Uploads can improve cross-region write performance without changing the overall architecture. + +##### Steps + +1. The frontend asks your app for upload permission. +2. Your Worker or backend authenticates the user and validates file type, size, and the target object key. +3. Your backend returns a short-lived presigned `PUT` URL. +4. The browser uploads directly to R2. +5. Your app stores the object key and metadata, not the presigned URL. + +> **Tip โ€” Store object keys, not presigned URLs** +> +> Presigned URLs are temporary access tokens. The durable thing your app should remember is the object key plus the metadata you care about. + +#### Choose the file-delivery pattern by who should be able to read the object + +Cloudflare's public bucket docs are clear about this split: custom domains are the right place for cache, WAF, Access, and other edge controls, while `r2.dev` is a development-oriented public URL and should not be treated as the polished product surface. + +When the content is private or app-controlled, the safest default is still a private bucket with a Worker route in front of it. That keeps auth and response headers under your control instead of forcing the bucket URL to become your application boundary. + +##### Highlights + +- **Public buckets** โ€” Covers custom domains, caching, access control, and the `r2.dev` production warning. ([link](https://developers.cloudflare.com/r2/buckets/public-buckets/)) +- **Protect an R2 bucket with Access** โ€” Best when the audience is your own organization rather than anonymous or app-authenticated users. ([link](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/)) +- **Configure token authentication** โ€” Use this when expiring custom-domain media links should be validated with WAF HMAC rules instead of R2 presigned URLs. ([link](https://developers.cloudflare.com/waf/custom-rules/use-cases/configure-token-authentication/)) + +##### Reference table + +| Pattern | Use it when | Main caveat | +| --- | --- | --- | +| Public bucket on a custom domain | Images, assets, or media should be public and cacheable for anyone. | Use a custom domain for real delivery; `r2.dev` is not the production path. | +| Private bucket plus Worker-gated reads | Access depends on the current user, tenant, payment state, or other app authorization. | Your Worker becomes the delivery boundary, so own the auth, cache headers, and response metadata deliberately. | +| Presigned `GET` URL on the S3 endpoint | A download should be directly accessible for a short time without a custom delivery layer. | Presigned URLs are bearer tokens and do not work with custom domains. | +| Custom domain plus Cloudflare Access | Only teammates or organization users should reach the bucket. | Disable `r2.dev` so the bucket is not still reachable through the public development URL. | +| Custom domain plus Worker token auth or WAF HMAC validation | You want expiring direct links on `cdn.example.com` without exposing the whole bucket. | This is not the same feature as presigned R2 URLs; you are building or validating the access layer at the custom domain boundary. | + +#### Keep development and production boundaries honest + +Cloudflare's development guidance says local Worker development uses local simulated bindings by default, and Devflare follows the same practical posture: local R2 bindings are available to your worker code, tests, and bridge helpers without requiring a real remote bucket just to iterate. + +Browser-visible local file flows should go through your Worker routes or app routes. Devflare does not promise a stable browser-facing local bucket origin, and depending on one would make local behavior more brittle than the product boundary probably needs to be. + +##### Key points + +- Only connect local development to a real remote bucket when you intentionally need integration testing. +- Use separate development, staging, or preview buckets instead of production buckets when remote R2 access becomes necessary. +- Remote bindings touch real data, incur real costs, and add real latency. +- In production, use a custom domain, choose public versus private delivery intentionally, configure CORS deliberately, and consider Local Uploads when uploaders are globally distributed. + +> **Warning โ€” Remote dev is not a harmless toggle** +> +> If your local Worker talks to a remote bucket, it is touching real data and real billing surfaces. Prefer separate dev or preview buckets, and avoid pointing local workflows at production uploads unless the test truly requires it. + +##### Example โ€” Serve a private object through the Worker in local dev and production + +```ts +import type { FetchEvent } from 'devflare/runtime' + +export async function GET({ env, params }: FetchEvent): Promise { + const object = await env.FILES.get(params.key) + if (!object) { + return new Response('Not Found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', + 'Cache-Control': 'private, max-age=0' + } + }) +} +``` + +#### A sane default architecture + +##### Highlights + +- **R2 binding guide** โ€” Open this once the architecture choice is done and the next question is the exact binding shape, local runtime behavior, or testing posture. ([link](/docs/bindings/r2)) +- **Preview-scoped bindings** โ€” Open this when preview deployments should own separate buckets or other disposable infrastructure that can be cleaned up by scope later. ([link](/docs/config-previews)) +- **createTestContext()** โ€” Open this when the next question is how the local worker-shaped test harness exposes real R2 bindings and helper surfaces. ([link](/docs/create-test-context)) + +##### Key points + +- Public assets โ†’ public bucket plus custom domain. +- User uploads โ†’ presigned `PUT` upload plus object key stored in D1 or another app database. +- Private assets โ†’ private bucket plus Worker-gated reads. +- Internal assets โ†’ custom domain plus Cloudflare Access. +- Custom-domain expiring links โ†’ Worker token auth or WAF HMAC validation. +- Preview-owned buckets โ†’ pair the R2 binding with `preview.scope()` so preview cleanup can remove the preview bucket without touching production storage. + +> **Note โ€” If you only remember one rule** +> +> Use presigned URLs for short-lived direct R2 access, but use a Worker or custom-domain auth layer for polished private media delivery. + +--- + +### Choose Durable Objects for single-identity state, queues for deferred work, and the binding guides for the mechanics + +> Use Durable Objects when one identity should own state or coordination. Use queues when work should happen later, in batches, or with retries. Then open the specific binding guide once the pattern is clear. + +| Field | Value | +| --- | --- | +| Route | [`/docs/durable-objects-and-queues`](/docs/durable-objects-and-queues) | +| Group | Guides | +| Navigation title | State & async patterns | +| Eyebrow | Binding strategy | + +This page is the pattern chooser for stateful or deferred work. It should help you decide when a Durable Object, a queue, or a mix of both fits the job without turning into a duplicate reference page for either binding. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Choosing between stateful identities, background work, or a mix of both | +| Choose by | State ownership vs deferred work ownership | +| Best local proof | One real object call or one real queue trigger through the default harness | +| Preview warning | Durable Object-heavy previews and queue-owned resources have different release questions | + +#### Choose the primitive by ownership, not by vibes + +The decision is easier when you ask who owns the work. If one stateful identity should serialize and own it, that points toward Durable Objects. If the request can accept the work and let something else finish it later, that points toward queues. + +Once that choice is made, the specific binding guide should take over so this page does not try to restate every authoring and testing rule for both bindings. + +##### Reference table + +| Pattern | Reach for it when | Usually the wrong fit | +| --- | --- | --- | +| `Durable Objects` | One identity should own state, coordination, ordering, alarms, or WebSocket-adjacent behavior. | The work is fire-and-forget, batchable, or mainly about retries. | +| `Queues` | The request can enqueue work and return while a consumer handles retries, batching, or slow follow-up tasks. | The user needs the state transition to finish synchronously in the request path. | +| `Use both` | A request or Durable Object owns the immediate state, then enqueues slower side work such as email, indexing, or downstream writes. | One primitive already tells the whole story and the second one would only add ceremony. | + +> **Note โ€” The point is pattern fit, not duplicate reference docs** +> +> If you already know you need a Durable Object or a queue, the binding guide is the next page. This page is here for the choice, not the full mechanics. + +#### Keep the config shapes explicit once you know the pattern + +Both patterns work better when the binding contract is visible in config. Durable Objects should name the object classes or refs clearly, and queues should keep producers, consumers, and dead-letter rules in one authored shape instead of hiding them in deployment-only conventions. + +##### Example โ€” Durable Object binding authoring should stay boring and explicit + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'stateful-worker', + files: { + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + durableObjects: { + LOGGER: { + className: 'Logger' + } + } + } +}) +``` + +##### Example โ€” Queue config should keep producer and consumer ownership visible + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-worker', + bindings: { + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue', + deadLetterQueue: 'task-queue-dlq' + } + ] + } + } +}) +``` + +#### Testing and preview questions are different for the two patterns + +##### Highlights + +- **Preview strategies** โ€” Open this when the real question is how Durable Objects or preview-scoped queue resources change the preview model. ([link](/docs/preview-strategies)) +- **Testing overview** โ€” Use the testing map when the next question is about the right harness or which docs own the testing guidance. ([link](/docs/testing-overview)) + +##### Key points + +- Durable Object tests are best at local object behavior, identity lookup, and stateful coordination. They do not replace migration or preview-topology checks. +- Queue tests are best at direct consumer behavior, retries, batching, and side effects through `cf.queue.trigger()`. They do not replace preview resource lifecycle checks. +- Durable Object-heavy preview flows deserve extra care because same-worker preview URLs and migrations have real platform caveats. +- If the real question is no longer โ€œwhich primitive fits?โ€ switch to the binding guide or the preview docs before this page starts repeating them badly. + +#### Open the binding guide once the pattern is obvious + +##### Highlights + +- **Durable Objects** โ€” Open the Durable Objects guide for the real binding shape, local tests, migrations, and preview caveats. ([link](/docs/bindings/durable-objects)) +- **Queues** โ€” Open the Queues guide for producer and consumer authoring, queue tests, and preview resource lifecycle details. ([link](/docs/bindings/queues)) + +--- + +### Compose worker families with service bindings when another worker is a real dependency + +> Use this page for the architecture question: when a separate worker boundary is justified, how `ref()` and service bindings keep it explicit, and where local tests and release checks should prove the wiring. + +| Field | Value | +| --- | --- | +| Route | [`/docs/multi-workers`](/docs/multi-workers) | +| Group | Guides | +| Navigation title | Worker composition | +| Eyebrow | Composition | + +The Services guide can explain the mechanics. This page exists for the composition question: when should another worker exist at all, how do you keep the boundary explicit, and which docs own the deeper service details once you commit to it? + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Service bindings, worker families, and deciding when another worker boundary is actually real | +| Core tools | `ref()`, service bindings, and generated env types | +| Best local proof | `createTestContext()` plus one real service call through `env.MY_SERVICE` | +| Main release risk | Resolved worker naming and preview topology drift | + +#### Choose another worker only when the boundary is real + +The goal is not to split one worker just because the file count went up. The goal is to give a real runtime boundary a real worker boundary, then let service bindings make that relationship explicit enough for tooling and review. + +That means this page should answer the architecture choice first. The Services guide can take over once the answer is already โ€œyes, another worker should exist.โ€ + +##### Reference table + +| If the real thing is... | Prefer... | Why | +| --- | --- | --- | +| A separate runtime capability or internal API | `Service bindings` and another worker | The boundary is a real worker-to-worker relationship, not just shared state. | +| One stateful identity or serialized mutation lane | `Durable Objects` | The core need is state ownership, not another general-purpose service boundary. | +| Shared data, files, or a background job handoff | `KV`, `D1`, `R2`, or `Queues` | The problem is data or deferred work, not a second worker API. | + +> **Note โ€” A good review question** +> +> Ask โ€œwhat does this second worker own that a binding or Durable Object would not?โ€ before you celebrate the split. + +#### Model the relationship with `ref()` so the worker family stays explicit + +If another worker is real, the relationship belongs in config instead of in copied worker names or half-remembered script references. `ref()` gives Devflare enough structure to follow the dependency into local runtime, generated env types, and compiled output. + +Keep the architecture example simple: one referenced worker and one explicit service binding are enough to show the boundary. Named entrypoints are real too, but the Services and generated-types pages own that deeper contract once the worker boundary itself is already justified. + +##### Example โ€” Model the worker family with `ref()` and one explicit service binding + +```ts +import { defineConfig, ref } from 'devflare/config' + +const mathWorker = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + bindings: { + services: { + MATH_SERVICE: mathWorker.worker + } + } +}) +``` + +#### Prove the wiring locally, then validate the names before release + +The shortest truthful proof is one real service call through the generated env binding. That already shows the config relationship, the local multi-worker setup, and the callable surface the gateway worker will actually use. + +But the release question is still different: local tests prove the call path, not that preview or production worker names resolve the way you intended. + +##### Key points + +- Use the bound env service directly when the worker relationship is the thing you want to prove. +- Refresh generated types when the service contract changes, and open the generated types page when named entrypoints become part of that contract. +- Preview isolation follows resolved worker names, not just which branch variable existed in CI. +- Validate compiled or preview naming when the worker family is business-critical. + +##### Example โ€” One real service call through the default harness + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('service binding calls the default worker export', async () => { + expect(await env.MATH_SERVICE.add(5, 3)).toBe(8) +}) +``` + +#### Open the service-specific pages once the architecture choice is done + +##### Highlights + +- **Services guide** โ€” Open the service guide for the exact binding shape, env typing, and compiler behavior once another worker is definitely the right boundary. ([link](/docs/bindings/services)) +- **Testing Services** โ€” Open the service testing guide when the next question is the right default harness or how to test named entrypoints accurately. ([link](/docs/bindings/services/testing)) +- **Generated types** โ€” Open this page when `ref()` relationships, named entrypoints, or `defineConfig()` typing becomes the real question. ([link](/docs/generated-types)) +- **Preview strategies** โ€” Open the preview page when the worker family needs real isolation and the naming model is the release question now. ([link](/docs/preview-strategies)) +- **Testing overview** โ€” Use the testing map when the next question is broader than service bindings alone. ([link](/docs/testing-overview)) + +--- + +### Use KV for fast lookup state without losing a real local loop + +> Author stable KV names in config, keep env typed, and run real get or put flows locally. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/kv`](/docs/bindings/kv) | +| Group | Bindings | +| Navigation title | KV | +| Eyebrow | Binding reference | + +Devflare lets you keep KV intent human-readable in `devflare.config.ts` and only resolve opaque namespace ids when build or deploy flows actually need them. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.kv` | +| Authoring shape | `Record` | +| Best for | Cache-like lookups, sessions, feature flags, and lightweight request metadata | + +#### Add the binding to config + +KV is happiest when you keep the namespace name stable in authored config and let Devflare resolve ids later. That keeps reviews readable and avoids hiding infrastructure intent in random environment variables. + +When you truly already know the namespace id, Devflare accepts that too. The important part is that both shapes compile down to the same deploy-facing contract. + +##### Example โ€” KV authoring with stable names or explicit ids + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kv-worker', + bindings: { + kv: { + CACHE: 'cache-kv', + SESSIONS: { name: 'sessions-kv' }, + REPORTING_CACHE: { id: 'kv-namespace-id' } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first KV path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” A tiny fetch handler that uses KV + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/write') { + await env.CACHE.put('hello', 'from-kv') + return new Response('stored') + } + + return new Response((await env.CACHE.get('hello')) ?? 'missing') +} +``` + +#### Local and Remote Support + +Devflare can run useful KV application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and tests. Start locally with `createTestContext()` plus `env.CACHE` or `cf.worker.fetch()`; that lane should cover the normal KV application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only KV details. + +#### When this binding fits best + +##### Key points + +- Reach for KV when reads are by key and you do not need relational queries. +- It is a good home for feature flags, lightweight session markers, or cache records that are cheap to recompute. +- If you need SQL, batch transactions, or richer query patterns, use D1 instead of forcing KV to act like a database. + +#### Testing path + +##### Key points + +- Rerun `devflare types` after adding or renaming a binding so the generated env contract stays honest. +- Preview-scoped names work well for namespace-per-branch flows, but they are still a naming strategy worth reviewing. +- KV is local-friendly, but account-level provisioning behavior still belongs in build, preview, or deploy checks when the lifecycle matters. + +> **Note โ€” The safest authoring instinct** +> +> Prefer stable names in source and let Devflare resolve ids later. It keeps config readable without giving up deploy-ready output. + +#### Open the next page when you need it + +##### Highlights + +- **KV internals** โ€” Check emitted Wrangler `kv_namespaces`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/kv/internals)) +- **Testing KV** โ€” Pick the `createTestContext()` plus `env.CACHE` or `cf.worker.fetch()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/kv/testing)) +- **KV example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/kv/example)) + +--- + +### How Devflare wires KV from config to runtime + +> KV goes through the full Devflare pipeline: normalize authoring, resolve names when needed, then compile to Wrangler output. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/kv/internals`](/docs/bindings/kv/internals) | +| Group | Bindings | +| Navigation title | KV internals | +| Eyebrow | Under the hood | + +The important detail is that Devflare does not force ids too early. It keeps stable names readable in source and only turns them into deploy-ready output in flows that truly require it. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | String and `{ name }` forms both normalize to name-based bindings first | +| Compile target | Wrangler `kv_namespaces` | +| Preview note | Preview-scoped KV namespaces can be provisioned and cleaned up automatically | + +#### How authored config becomes Wrangler config + +`bindings.kv` accepts a plain string, `{ name }`, or `{ id }`. Devflare normalizes those into one internal shape so later code can reason about them consistently. + +Authored config can stay human-readable without making compiler or deploy code guess what each record means at the last second. + +##### Example โ€” KV config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kv-worker', + bindings: { + kv: { + CACHE: 'cache-kv', + SESSIONS: { name: 'sessions-kv' }, + REPORTING_CACHE: { id: 'kv-namespace-id' } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "kv_namespaces": [ + { "binding": "CACHE", "id": "kv-namespace-id" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Local runtime resolution can keep the configured name as the local namespace identifier instead of forcing a Cloudflare API lookup. +- The env proxy supports the real KV methods you expect in worker code, including `get`, `put`, `delete`, `list`, and `getWithMetadata`. +- If you only need isolated unit tests, the repo also exposes `createMockKV()` and `createMockEnv()` helpers. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Build and deploy flows resolve stable namespace names into ids when the output must be Wrangler-ready. +- If unresolved name-based KV bindings remain at compile time, Devflare rejects the config instead of silently guessing. +- Preview-scoped KV names are treated as lifecycle-managed resources, so branch-specific namespaces can be provisioned and cleaned up deliberately. + +> **Tip โ€” Why the split matters** +> +> Authored config can stay stable and readable even though deploy output eventually needs concrete ids. That separation is a big part of why KV feels pleasant in Devflare. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Workers KV docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.kv`. + +##### Highlights + +- **Cloudflare Workers KV docs** โ€” Platform reference for KV namespaces, binding APIs, limits, and Wrangler-facing setup. ([link](https://developers.cloudflare.com/kv/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for KV namespaces, binding APIs, limits, and Wrangler-facing setup. | How to author `bindings.kv`, what the runtime surface looks like, and how KV fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test KV the way Devflare expects it to run + +> Use the default test harness first. KV is one of the bindings Devflare supports best in local tests. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/kv/testing`](/docs/bindings/kv/testing) | +| Group | Bindings | +| Navigation title | Testing KV | +| Eyebrow | Testing | + +When you call `createTestContext()`, KV namespaces are wired into the same env contract your worker code uses. That lets you test reads and writes without inventing a fake abstraction first. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Worker tests that read and write real KV values through the local harness | +| Default harness | `createTestContext()` plus `env.CACHE` or `cf.worker.fetch()` | +| Escalate when | You need to verify provisioning, preview naming, or account-side behavior | + +#### Start with the default test loop + +Start small: create the test context, write a value, read it back, and only then move outward to HTTP or queue-driven flows. + +If the binding matters because a route uses it, test through that route. If the binding itself is the thing you are verifying, talk to `env.CACHE` directly. + +##### Example โ€” Testing KV through the real Devflare env + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('stores and reads a cache value', async () => { + await env.CACHE.put('feature:search', 'on') + expect(await env.CACHE.get('feature:search')).toBe('on') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `env.CACHE` or the specific KV binding directly when you want the shortest binding-focused assertion. +- Use `cf.worker.fetch()` if the behavior only matters once a request has gone through your real handler. +- Use `createMockKV()` only when the test truly should not boot the runtime-shaped harness. + +#### When to move beyond the default harness + +##### Key points + +- Local KV tests are excellent for behavior and shape, but they do not replace deploy-time checks for account provisioning or preview cleanup. +- If a test is really about routing, auth, or caching headers, keep the assertion at the worker level instead of overfocusing on the namespace API. +- Preview-specific namespace naming is worth one dedicated integration check when branch isolation matters. + +> **Important โ€” A good default split** +> +> Test binding semantics locally and test lifecycle semantics in preview or deploy-oriented paths. Trying to make one test do both usually makes it worse at each job. + +--- + +### Use KV in a real application path + +> This example keeps KV simple: one binding, one fetch handler, one assertion. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/kv/example`](/docs/bindings/kv/example) | +| Group | Bindings | +| Navigation title | KV example | +| Eyebrow | Application example | + +The fastest way to trust a binding is to wire one small use case end to end before you hide it behind a bigger app. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Stable namespace naming | +| Runtime shape | Direct `put()` and `get()` calls in a fetch handler | +| Best use | A tiny cache or session-marker flow | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal KV config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'kv-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { + CACHE: 'cache-kv' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level KV path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Prefer a tiny route like this before you wrap KV behind a helper or service layer. + +##### Example โ€” A tiny fetch handler that uses KV + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/write') { + await env.CACHE.put('hello', 'from-kv') + return new Response('stored') + } + + return new Response((await env.CACHE.get('hello')) ?? 'missing') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Stable namespace naming. +- Runtime shape: Direct `put()` and `get()` calls in a fetch handler. +- Best use: A tiny cache or session-marker flow. + +> **Note โ€” Start with the boring shape** +> +> If the first KV example already feels abstract, it is probably hiding the actual binding semantics instead of teaching them. + +--- + +### Use D1 when the worker wants real queries instead of key-value tricks + +> D1 gets the same stable-name authoring story as KV, but the runtime shape is relational: `prepare`, `batch`, `exec`, and prepared statements. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/d1`](/docs/bindings/d1) | +| Group | Bindings | +| Navigation title | D1 | +| Eyebrow | Binding reference | + +Devflare keeps D1 readable in config and testable in local runtime, which means you can model actual query behavior before you wire up preview or deploy steps. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.d1` | +| Authoring shape | `Record` | +| Best for | Structured data, SQL queries, and cases where key-based lookup is not enough | + +#### Add the binding to config + +D1 follows the same stable-name instinct as KV: author by readable name unless you intentionally already have a database id you want to pin to. + +In reviews, look for human-meaningful names in source. Inspect generated or resolved output only when a deploy flow needs it. + +##### Example โ€” D1 binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'd1-worker', + bindings: { + d1: { + DB: 'app-db', + AUDIT: { name: 'audit-db' }, + REPORTING: { id: 'd1-database-id' } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first D1 path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” A tiny route that proves the binding works + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const row = await env.DB.prepare('select 1 as ok').first<{ ok: number }>() + return Response.json({ ok: row?.ok === 1 }) +} +``` + +#### Local and Remote Support + +Devflare can run useful D1 application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and tests. Start locally with `createTestContext()` with `env.DB` or `cf.worker.fetch()`; that lane should cover the normal D1 application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only D1 details. + +#### When this binding fits best + +##### Key points + +- Use D1 when the worker needs SQL, joins, or a schema that should be queried instead of fetched by a single key. +- It fits better than KV for records that need filtering, ordering, or transactional updates. +- If the only operation is key lookup or a tiny cache record, KV usually stays simpler. + +#### Testing path + +##### Key points + +- Run `devflare types` after binding changes so the database bindings show up correctly in `env.d.ts`. +- Preview-scoped databases are useful when branch data must stay isolated, but they should still be provisioned and cleaned up deliberately. +- Name-based D1 authoring is readable, but build and deploy still need a path that resolves those names to ids before output is treated as final. + +> **Note โ€” Do not hide the database shape** +> +> The point of D1 docs is to keep SQL visible enough that reviewers can still understand what the worker is doing, not to hide every query behind framework glue. + +#### Open the next page when you need it + +##### Highlights + +- **D1 internals** โ€” Check emitted Wrangler `d1_databases`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/d1/internals)) +- **Testing D1** โ€” Pick the `createTestContext()` with `env.DB` or `cf.worker.fetch()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/d1/testing)) +- **D1 example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/d1/example)) + +--- + +### How Devflare wires D1 from config to runtime + +> D1 uses the same normalize-then-resolve pattern as KV, but compiles to Wrangler `d1_databases` and exposes a relational local runtime surface. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/d1/internals`](/docs/bindings/d1/internals) | +| Group | Bindings | +| Navigation title | D1 internals | +| Eyebrow | Under the hood | + +The key implementation detail is that Devflare can keep a stable database name around until a flow truly needs the real database id. That keeps config readable without giving up deploy precision. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Name-based authoring stays name-based until a build or deploy flow resolves it | +| Compile target | Wrangler `d1_databases` | +| Preview note | Preview-scoped D1 databases can be provisioned and cleaned up by Devflare | + +#### How authored config becomes Wrangler config + +Like KV, D1 bindings normalize into one internal shape so compiler and runtime code do not need to special-case string versus object authoring everywhere. + +That normalized form is what lets Devflare keep the friendly source-of-truth shape while still generating strict Wrangler-facing output later. + +##### Example โ€” D1 config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'd1-worker', + bindings: { + d1: { + DB: 'app-db', + AUDIT: { name: 'audit-db' }, + REPORTING: { id: 'd1-database-id' } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "d1_databases": [ + { "binding": "DB", "database_id": "d1-database-id" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- The local bridge exposes the D1 APIs people actually use: `prepare()`, `batch()`, `exec()`, and the prepared-statement helpers like `first`, `all`, `run`, and `raw`. +- `createTestContext()` can boot those bindings without a custom mock layer, which is why D1 tests can stay close to production query code. +- If you only need isolated unit tests, `createMockD1()` exists, but it is usually weaker than the full runtime-shaped harness. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Build and deploy resolve name-based D1 records to real database ids before Devflare emits compiled config. +- Compile rejects unresolved name-based D1 bindings instead of silently producing half-finished Wrangler output. +- Preview resource management can create and later remove branch-specific D1 databases when the preview model truly owns separate data. + +> **Tip โ€” Same authoring rule, different runtime shape** +> +> The config story is close to KV, but the runtime story is SQL-shaped โ€” as it should be. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare D1 docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.d1`. + +##### Highlights + +- **Cloudflare D1 docs** โ€” Platform reference for D1 databases, Worker APIs, migrations, and database limits. ([link](https://developers.cloudflare.com/d1/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for D1 databases, Worker APIs, migrations, and database limits. | How to author `bindings.d1`, what the runtime surface looks like, and how D1 fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test D1 the way Devflare expects it to run + +> D1 is one of the easiest bindings to test meaningfully with Devflare because the local runtime already speaks the same database API your worker uses. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/d1/testing`](/docs/bindings/d1/testing) | +| Group | Bindings | +| Navigation title | Testing D1 | +| Eyebrow | Testing | + +Start with `createTestContext()`, then either query the database directly through `env.DB` or exercise it through your real routes. Both are normal, not exotic. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Query behavior, route-level database flows, and schema-aware worker tests | +| Default harness | `createTestContext()` with `env.DB` or `cf.worker.fetch()` | +| Escalate when | You need migration, provisioning, or branch-scoped preview verification | + +#### Start with the default test loop + +The cleanest D1 test loop mirrors how the worker really behaves: boot the test context, run a small query, and assert the returned row or route result. + +If a helper wraps the query logic, keep one direct database test around anyway so the underlying binding contract stays visible. + +##### Example โ€” A tiny D1 test through the local harness + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('D1 answers a simple health query', async () => { + const row = await env.DB.prepare('select 1 as ok').first<{ ok: number }>() + expect(row?.ok).toBe(1) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `env.DB` when the binding itself is the thing you care about. +- Use `cf.worker.fetch()` when the database matters because a route, queue consumer, or other handler reaches it. +- Keep the schema setup close to the test when possible so the query story stays visible. + +#### When to move beyond the default harness + +##### Key points + +- Local tests are excellent for query logic, but they are not a substitute for migration review or account-side database provisioning checks. +- If the assertion is really about a business route, do not collapse the entire behavior down to one raw SQL assertion and pretend that is the full story. +- Preview-specific D1 isolation is worth its own higher-level check when branch data boundaries matter. + +> **Warning โ€” Do not let SQL disappear into helper fog** +> +> One reason D1 feels good in Devflare is that the runtime API is still recognizable. Keep at least one test close enough to see the actual query behavior. + +--- + +### Use D1 in a real application path + +> This starter example keeps D1 focused on one job: answer a single query and prove the binding works locally. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/d1/example`](/docs/bindings/d1/example) | +| Group | Bindings | +| Navigation title | D1 example | +| Eyebrow | Application example | + +You do not need a giant ORM story to prove D1 is wired correctly. One table-shaped query is already enough to make the point. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Stable database naming | +| Runtime shape | Prepared statement query in a fetch handler | +| Best use | Health checks, small lookup routes, and early schema experiments | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal D1 config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'd1-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + d1: { + DB: 'app-db' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level D1 path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- You can replace the health query with a real table lookup later without changing the binding shape. +- Keep one route like this around if you want a cheap deploy smoke path for D1. + +##### Example โ€” A tiny route that proves the binding works + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const row = await env.DB.prepare('select 1 as ok').first<{ ok: number }>() + return Response.json({ ok: row?.ok === 1 }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Stable database naming. +- Runtime shape: Prepared statement query in a fetch handler. +- Best use: Health checks, small lookup routes, and early schema experiments. + +> **Note โ€” The first example does not need a migration epic** +> +> Prove the binding first. Add richer schema setup only after the worker already has one truthful D1 path. + +--- + +### Use R2 for object storage, but route browser delivery deliberately + +> R2 is straightforward in config and well-supported locally, but browser-facing delivery should usually go through a Worker route instead of assuming bucket URLs. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/r2`](/docs/bindings/r2) | +| Group | Bindings | +| Navigation title | R2 | +| Eyebrow | Binding reference | + +R2 works in worker code and tests. The main discipline is deciding which files are public, which are private, and which paths should stay app-controlled. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.r2` | +| Authoring shape | `Record` | +| Best for | Files, uploads, generated assets, and private object delivery through a Worker | + +#### Add the binding to config + +R2 is the least ambiguous storage binding to author: you bind a name in env to a bucket name in config. + +The real architectural choice is not the config key. It is whether the browser talks to a public bucket, a signed upload path, or a worker-controlled route that checks auth first. + +##### Example โ€” R2 binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'r2-worker', + bindings: { + r2: { + ASSETS: 'assets-bucket', + PRIVATE_FILES: 'private-files-bucket' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first R2 path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Serve an object through the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const key = url.pathname.replace(/^\/files\//, '') + const object = await env.FILES.get(key) + + if (!object) { + return new Response('Not found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream' + } + }) +} +``` + +#### Local and Remote Support + +Devflare can run useful R2 application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and tests. Start locally with `createTestContext()` with `env.ASSETS` or `cf.worker.fetch()`; that lane should cover the normal R2 application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only R2 details. + +#### When this binding fits best + +##### Key points + +- Use R2 for large objects, uploads, or file delivery that does not belong in D1 or KV. +- Keep private file delivery in a Worker route so auth and response headers stay under your control. +- If the browser needs a direct public asset origin, use a public bucket on a custom domain rather than by accident. + +#### Testing path + +##### Key points + +- Do not assume local bucket URLs are a public contract your app can safely depend on. +- Use `devflare types` after binding changes so bucket names show up correctly in `env.d.ts`. +- Preview-scoped buckets are useful, but they should still be cleaned up intentionally when previews expire. + +> **Warning โ€” The browser-delivery rule** +> +> If the browser needs the file in local dev, route through your worker unless you intentionally chose a public bucket contract. + +#### Open the next page when you need it + +##### Highlights + +- **R2 internals** โ€” Check emitted Wrangler `r2_buckets`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/r2/internals)) +- **Testing R2** โ€” Pick the `createTestContext()` with `env.ASSETS` or `cf.worker.fetch()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/r2/testing)) +- **R2 example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/r2/example)) + +--- + +### How Devflare wires R2 from config to runtime + +> R2 is simpler than KV or D1 because the authored value is already the bucket name, so there is no name-versus-id resolution dance. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/r2/internals`](/docs/bindings/r2/internals) | +| Group | Bindings | +| Navigation title | R2 internals | +| Eyebrow | Under the hood | + +That simplicity is part of why R2 feels predictable in Devflare. The runtime and compiler story mostly focuses on wiring methods and generated output cleanly, not on translating names into ids. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | There is no separate id-resolution phase for the authored bucket name | +| Compile target | Wrangler `r2_buckets` | +| Preview note | Preview-scoped buckets can be provisioned and cleaned up by Devflare | + +#### How authored config becomes Wrangler config + +R2 is one of the cleanest bindings internally because the authored string is already the thing Wrangler expects later: the bucket name. + +That means Devflare mostly needs to preserve the mapping faithfully, generate output, and expose the runtime methods cleanly in local mode. + +##### Example โ€” R2 config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'r2-worker', + bindings: { + r2: { + ASSETS: 'assets-bucket', + PRIVATE_FILES: 'private-files-bucket' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "r2_buckets": [ + { "binding": "ASSETS", "bucket_name": "assets-bucket" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- The local bridge supports `head`, `get`, `put`, `delete`, and `list` on R2 buckets. +- Large `put()` operations can switch to HTTP transfer inside the bridge rather than trying to force every object body through one RPC path. +- `createMockR2()` exists for isolated tests, but the real local harness is usually the better default. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits `r2_buckets` directly from the authored mapping. +- Preview resource lifecycle code can materialize branch-scoped bucket names, provision them, and later clean them up. +- The browser URL story is intentionally left to your app architecture rather than being smuggled into the binding implementation. + +> **Note โ€” Simple binding, nontrivial delivery choices** +> +> R2 config is easy. The interesting decisions are about how files flow through your app, not about how many nested objects the config needs. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare R2 docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.r2`. + +##### Highlights + +- **Cloudflare R2 docs** โ€” Platform reference for buckets, object APIs, public-versus-private delivery, and account features. ([link](https://developers.cloudflare.com/r2/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for buckets, object APIs, public-versus-private delivery, and account features. | How to author `bindings.r2`, what the runtime surface looks like, and how R2 fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test R2 the way Devflare expects it to run + +> R2 is local-friendly, which means you can test real object operations without inventing a storage adapter just to get off the ground. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/r2/testing`](/docs/bindings/r2/testing) | +| Group | Bindings | +| Navigation title | Testing R2 | +| Eyebrow | Testing | + +Use the runtime-shaped harness for direct bucket tests, then move up to worker-level tests when headers, auth, or file routing matter. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Object reads, writes, deletes, and route-level file-serving checks | +| Default harness | `createTestContext()` with `env.ASSETS` or `cf.worker.fetch()` | +| Escalate when | You need to verify public delivery contracts or preview resource lifecycle | + +#### Start with the default test loop + +R2 tests can be extremely small: put one object, read it back, and confirm the content or headers through the same worker path users will actually hit. + +That is often enough to prove the binding, while the route test proves your app-level delivery rules. + +##### Example โ€” Testing a real R2 binding + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('stores and reads an object', async () => { + await env.ASSETS.put('hello.txt', 'from-r2') + const object = await env.ASSETS.get('hello.txt') + expect(await object?.text()).toBe('from-r2') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `env.ASSETS` when you are verifying the bucket contract itself. +- Use `cf.worker.fetch()` when the route, auth, or response metadata is the thing that matters. +- Keep at least one test close to the bucket API so the storage shape stays visible. + +#### When to move beyond the default harness + +##### Key points + +- A passing local bucket test does not mean your public asset topology is good; that still belongs to route and deployment design. +- If the browser-facing path matters, assert the worker response instead of treating a bucket read as the whole user story. +- Bucket provisioning and cleanup belong in preview or deploy-oriented checks when branch infrastructure matters. + +> **Warning โ€” Test the right layer** +> +> An object round-trip proves the binding. It does not automatically prove your file-delivery architecture. + +--- + +### Use R2 in a real application path + +> This example uses one private bucket and one route, which is still the cleanest default shape for many real apps. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/r2/example`](/docs/bindings/r2/example) | +| Group | Bindings | +| Navigation title | R2 example | +| Eyebrow | Application example | + +A good first R2 example teaches both the binding and the delivery boundary: the worker decides what the browser gets. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Direct bucket naming | +| Runtime shape | Get an object from R2 and stream it through a route | +| Best use | Private file delivery or media endpoints | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal R2 config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'r2-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + r2: { + FILES: 'private-files' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level R2 path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- This route pattern keeps auth, caching, and content-type decisions in your app instead of in an assumed bucket URL contract. +- If you later choose a public bucket, make that an explicit architecture decision rather than a hidden side effect. + +##### Example โ€” Serve an object through the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const key = url.pathname.replace(/^\/files\//, '') + const object = await env.FILES.get(key) + + if (!object) { + return new Response('Not found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream' + } + }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Direct bucket naming. +- Runtime shape: Get an object from R2 and stream it through a route. +- Best use: Private file delivery or media endpoints. + +> **Note โ€” A better first instinct than โ€œjust use the bucket URLโ€** +> +> Routing through the worker teaches the real boundary between stored objects and browser-facing responses. + +--- + +### Use Durable Objects when coordination or state really belongs with a single object identity + +> The fast Devflare payoff is simple: put one counter object in a `do.*` file, call it from the worker, and call the same object directly in tests. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/durable-objects`](/docs/bindings/durable-objects) | +| Group | Bindings | +| Navigation title | Durable Objects | +| Eyebrow | Binding reference | + +Devflare auto-discovers `**/do.*.{ts,js}` by default, wires the Durable Object binding into the worker env, and lets tests use the same namespace without making you invent a fake DO harness first. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.durableObjects` | +| Authoring shape | `Record` | +| Best for | Stateful sessions, locks, room state, and coordination that should not be faked as random stateless requests | + +#### Add the binding to config + +The easiest honest starting point is one local Durable Object class and one binding that points at it by class name. + +If the class lives in a `do.*` file, Devflare discovers it with the default `**/do.*.{ts,js}` pattern, so the first example does not need extra DO file config. + +##### Example โ€” Start with one discovered Durable Object and one binding + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'counter-worker', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Durable Objects path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” A tiny object and one worker path + +###### File โ€” src/do.counter.ts + +```ts +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async increment(amount = 1): Promise { + const current = (await this.ctx.storage.get('value')) ?? 0 + const next = current + amount + await this.ctx.storage.put('value', next) + return next + } + + async getValue(): Promise { + return (await this.ctx.storage.get('value')) ?? 0 + } +} +``` + +###### File โ€” src/fetch.ts + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const counter = env.COUNTER.getByName('main') + + if (url.pathname === '/value') { + return Response.json({ value: await counter.getValue() }) + } + + return Response.json({ value: await counter.increment() }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Durable Objects application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and tests, including cross-worker references. Start locally with `createTestContext()` with the real DO namespace in `env`; that lane should cover the normal Durable Objects application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Durable Objects details. + +#### When this binding fits best + +##### Key points + +- Use Durable Objects when state or coordination should live behind one object identity, not when you merely want a fancy singleton. +- They are a good fit for counters, rooms, distributed locks, and request serialization. +- If the state is really just data you query, D1 or KV may stay simpler and easier to preview. + +#### Testing path + +##### Key points + +- DO-heavy apps need extra preview care because same-worker preview URLs do not cover every real DO deployment case. +- `wrangler versions upload` does not currently apply Durable Object migrations, so migration-sensitive previews need a stronger plan. +- Test and review worker naming carefully when DO bindings cross worker boundaries. + +> **Warning โ€” The preview caveat is real, not optional trivia** +> +> If previews must exercise real Durable Object behavior, branch-scoped preview workers are often safer than hoping same-worker preview URLs will be enough. + +#### Open the next page when you need it + +##### Highlights + +- **Durable Objects internals** โ€” Check emitted Wrangler `durable_objects.bindings`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/durable-objects/internals)) +- **Testing Durable Objects** โ€” Pick the `createTestContext()` with the real DO namespace in `env` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/durable-objects/testing)) +- **Durable Objects example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/durable-objects/example)) + +--- + +### How Devflare wires Durable Objects from config to runtime + +> Durable Object bindings normalize into a stable binding shape, compile into Wrangler `durable_objects.bindings`, and participate in Devflareโ€™s own DO bundling path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/durable-objects/internals`](/docs/bindings/durable-objects/internals) | +| Group | Bindings | +| Navigation title | Durable Objects internals | +| Eyebrow | Under the hood | + +This is one of the places where Devflare feels the most application-aware. It is not only compiling config โ€” it is discovering DO classes, bundling them, and keeping local runtime behavior coherent. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Local strings, explicit objects, and cross-worker refs normalize into one DO binding model | +| Compile target | Wrangler `durable_objects.bindings` | +| Preview note | DO apps often need branch-scoped preview workers instead of same-worker preview URLs | + +#### How authored config becomes Wrangler config + +DO bindings accept a string, an explicit `{ className, scriptName? }` object, or a cross-worker reference produced by `ref()`. Devflare normalizes those into one internal shape before later steps inspect them. + +That normalized shape is what lets config, compiler, and test-context setup all speak the same language even when a DO comes from another worker package. + +##### Example โ€” Durable Objects config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'counter-worker', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "durable_objects": { + "bindings": [ + { "name": "ROOM", "class_name": "ChatRoom" } + ] + } +} +``` + +#### What local runtime support covers + +##### Key points + +- The local test context can auto-detect cross-worker DO refs and stand up the required multi-worker Miniflare shape for them. +- The DO bundler discovers classes from `files.durableObjects`, emits worker-compatible code, and even handles special cases like `@cloudflare/puppeteer` usage. +- Tests can use the normal DO namespace ergonomics instead of a custom fake API surface. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits `class_name` and optional `script_name` for each binding, which is what Wrangler-facing output expects. +- Cross-worker DO references are resolved before compile output is treated as final. +- Preview and deploy workflows need to respect real DO migration and preview caveats instead of pretending the platform limitations disappeared. + +> **Important โ€” This is where coherent tooling matters most** +> +> If a tool cannot keep DO authoring, local runtime, and test setup coherent, DO-heavy apps get painful fast. Devflareโ€™s value is that these pieces stay part of one story. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Durable Objects docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.durableObjects`. + +##### Highlights + +- **Cloudflare Durable Objects docs** โ€” Platform reference for object identity, storage, alarms, migrations, and deployment caveats. ([link](https://developers.cloudflare.com/durable-objects/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for object identity, storage, alarms, migrations, and deployment caveats. | How to author `bindings.durableObjects`, what the runtime surface looks like, and how Durable Objects fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and tests, including cross-worker references. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Durable Objects the way Devflare expects it to run + +> Durable Objects are well-supported in the default Devflare harness, which means you can test real object behavior without hand-building a fake namespace first. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/durable-objects/testing`](/docs/bindings/durable-objects/testing) | +| Group | Bindings | +| Navigation title | Testing Durable Objects | +| Eyebrow | Testing | + +That support extends to cross-worker DO scenarios too, as long as the config relationships are explicit. The main testing question is whether you are checking local object behavior or deployment caveats. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Local stateful behavior, object methods, and cross-worker DO wiring checks | +| Default harness | `createTestContext()` with the real DO namespace in `env` | +| Escalate when | The question is preview URLs, migrations, or branch-scoped deploy behavior | + +#### Start with the default test loop + +Start by creating the test context and calling the object through its real namespace. That proves the binding, the identity lookup, and the object behavior in one go. + +Keep one test close to the object semantics even if your app later wraps DO access behind services or helper modules. + +##### Example โ€” Testing a Durable Object through the real namespace + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('the counter object increments', async () => { + const counter = env.COUNTER.getByName('main') + expect(await counter.increment()).toBe(1) + expect(await counter.increment()).toBe(2) + expect(await counter.getValue()).toBe(2) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use the real DO namespace in `env` whenever possible instead of a fake interface. +- If the object is reached through a route or another worker, keep a worker-level test around as well. +- Use cross-worker refs in config rather than loose string conventions so the test context can understand the relationship. + +#### When to move beyond the default harness + +##### Key points + +- Local DO tests do not replace migration reviews or branch-scoped preview checks. +- If the real risk is deployment naming or preview topology, write a higher-level preview test instead of stretching the local harness past its job. +- DO apps often need stronger preview isolation than a same-worker upload path can give them. + +> **Warning โ€” Separate object behavior from preview behavior** +> +> The default harness is excellent for object logic. It is not a substitute for the preview strategy decisions that DO-heavy apps still need. + +--- + +### Use Durable Objects in a real application path + +> A real Durable Objects application path with config and runtime code kept side by side. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/durable-objects/example`](/docs/bindings/durable-objects/example) | +| Group | Bindings | +| Navigation title | Durable Objects example | +| Eyebrow | Application example | + +Use this as the application-focused Durable Objects example before you add feature-specific abstractions around the binding. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Auto-discovered `do.*` file plus one DO binding | +| Runtime shape | Durable Objects calls from worker application code | +| Best use | Counters, room state, and small single-identity coordination examples | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal Durable Object config using the default discovery pattern + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'do-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + }, + migrations: [ + { + tag: 'v1', + new_classes: ['Counter'] + } + ] +}) + +// Devflare auto-discovers src/do.counter.ts via the default: +// durableObjects: '**/do.*.{ts,js}' +``` + +#### Build the application flow around the binding + +Treat this as the app-level Durable Objects path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- This tiny shape already proves that the object class, namespace, storage, and worker path are wired correctly. +- Once this works, richer room, session, or lock logic becomes a normal extension instead of a blind leap. + +##### Example โ€” A tiny object and one worker path + +###### File โ€” src/do.counter.ts + +```ts +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async increment(amount = 1): Promise { + const current = (await this.ctx.storage.get('value')) ?? 0 + const next = current + amount + await this.ctx.storage.put('value', next) + return next + } + + async getValue(): Promise { + return (await this.ctx.storage.get('value')) ?? 0 + } +} +``` + +###### File โ€” src/fetch.ts + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const counter = env.COUNTER.getByName('main') + + if (url.pathname === '/value') { + return Response.json({ value: await counter.getValue() }) + } + + return Response.json({ value: await counter.increment() }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Auto-discovered `do.*` file plus one DO binding. +- Runtime shape: Durable Objects calls from worker application code. +- Best use: Counters, room state, and small single-identity coordination examples. + +--- + +### Use Queues when work should happen later, in batches, or with retries + +> Devflare models Queue producers and consumers explicitly, which makes local tests and preview naming much easier to reason about. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/queues`](/docs/bindings/queues) | +| Group | Bindings | +| Navigation title | Queues | +| Eyebrow | Binding reference | + +The config shape keeps the relationship visible: which bindings can enqueue work, which consumer handles that queue, and how retries or dead-letter behavior should look. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.queues` | +| Authoring shape | `{ producers?: Record; consumers?: QueueConsumer[] }` | +| Best for | Background jobs, async processing, fan-out work, and controlled retry behavior | + +#### Add the binding to config + +Queues are easiest to understand when the producer names and consumer config live together in the same authored source of truth. + +That way the code review already shows who sends messages, who processes them, and where failures go when retries run out. + +##### Example โ€” Queue producer and consumer authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-worker', + bindings: { + queues: { + producers: { + JOBS: 'jobs-queue' + }, + consumers: [ + { + queue: 'jobs-queue', + deadLetterQueue: 'jobs-dlq', + maxRetries: 3 + } + ] + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Queues path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” One fetch path and one queue consumer + +```ts +import { env } from 'devflare/runtime' +import type { MessageBatch } from '@cloudflare/workers-types' + +export async function fetch(): Promise { + await env.JOBS.send({ id: 'job-1', createdAt: Date.now() }) + return new Response('queued', { status: 202 }) +} + +export async function queue(batch: MessageBatch<{ id: string }>): Promise { + for (const message of batch.messages) { + await env.RESULTS.put('job:' + message.body.id, 'done') + message.ack() + } +} +``` + +#### Local and Remote Support + +Devflare can run useful Queues application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and queue-trigger tests. Start locally with `createTestContext()` plus `cf.queue.trigger()`; that lane should cover the normal Queues application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Queues details. + +#### When this binding fits best + +##### Key points + +- Use Queues when the worker should hand work off instead of blocking the original request. +- They are a good fit for batch processing, notifications, post-request writes, and work that deserves retry control. +- If the task must happen synchronously in the request path, a queue is probably the wrong tool. + +#### Testing path + +##### Key points + +- Keep producer and consumer intent explicit so dead-letter and retry behavior is reviewable. +- Preview-scoped queues and DLQs are possible, but they should be created only when the preview really owns separate async infrastructure. +- Queue tests should separate handler behavior from wider route or scheduling concerns. + +> **Note โ€” The queue rule of thumb** +> +> If a request can safely say โ€œI accepted the workโ€ before the work is complete, queues are a good candidate. If not, keep it in the request path. + +#### Open the next page when you need it + +##### Highlights + +- **Queues internals** โ€” Check emitted Wrangler `queues.producers` and `queues.consumers`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/queues/internals)) +- **Testing Queues** โ€” Pick the `createTestContext()` plus `cf.queue.trigger()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/queues/testing)) +- **Queues example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/queues/example)) + +--- + +### How Devflare wires Queues from config to runtime + +> Queue config is compiled into explicit producer and consumer blocks, with preview resource materialization available for both queue names and DLQs. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/queues/internals`](/docs/bindings/queues/internals) | +| Group | Bindings | +| Navigation title | Queues internals | +| Eyebrow | Under the hood | + +This is one of the clearer compiler paths in Devflare: producers become env bindings, consumers become worker-side queue listeners, and preview lifecycle code can materialize names when the preview should own separate queues. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Producer and consumer config is split into one normalized queue model before compile | +| Compile target | Wrangler `queues.producers` and `queues.consumers` | +| Preview note | Preview queue names and DLQs can be provisioned and cleaned up when the preview owns them | + +#### How authored config becomes Wrangler config + +Devflare does not treat queue producers and queue consumers as unrelated configuration fragments. It keeps them in one coherent config namespace so later compile and preview code can see the whole story. + +Review and runtime stay aligned: the config already names the queue, the producer binding, the consumer, and the dead-letter relationship in one place. + +##### Example โ€” Queues config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-worker', + bindings: { + queues: { + producers: { + JOBS: 'jobs-queue' + }, + consumers: [ + { + queue: 'jobs-queue', + deadLetterQueue: 'jobs-dlq', + maxRetries: 3 + } + ] + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "queues": { + "producers": [ + { "binding": "JOBS", "queue": "jobs-queue" } + ], + "consumers": [ + { "queue": "jobs-queue", "dead_letter_queue": "jobs-dlq", "max_retries": 3 } + ] + } +} +``` + +#### What local runtime support covers + +##### Key points + +- The local harness can stand up queue producers as real env bindings and trigger the queue handler through test helpers. +- Queue helper behavior is different from plain worker fetch behavior because `cf.queue.trigger()` waits for queued background work before returning. +- That makes queue tests a good place to assert post-processing side effects directly. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile converts consumer options into the output shape Wrangler expects, including retry and dead-letter fields. +- Preview materialization can generate branch-specific queue and DLQ names when the preview environment should own separate async infrastructure. +- This lifecycle support covers queue resources more directly than service bindings, which mostly stay name-based references. + +> **Tip โ€” Queues stay reviewable when the config stays explicit** +> +> The combination of producers, consumers, and dead-letter settings is much easier to trust when it lives in one visible authored shape. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Queues docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.queues`. + +##### Highlights + +- **Cloudflare Queues docs** โ€” Platform reference for queue producers, consumers, delivery guarantees, retries, batching, and DLQs. ([link](https://developers.cloudflare.com/queues/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for queue producers, consumers, delivery guarantees, retries, batching, and DLQs. | How to author `bindings.queues`, what the runtime surface looks like, and how Queues fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and queue-trigger tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Queues the way Devflare expects it to run + +> Queue testing is one of the places where Devflareโ€™s helper surface feels especially good because the queue trigger already knows how to drive the real handler shape. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/queues/testing`](/docs/bindings/queues/testing) | +| Group | Bindings | +| Navigation title | Testing Queues | +| Eyebrow | Testing | + +That means you can test a queue consumer without bootstrapping your own fake message batch or pretending the queue handler is just a random function. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Queue consumer behavior, retries, and queue-driven side effects | +| Default harness | `createTestContext()` plus `cf.queue.trigger()` | +| Escalate when | You need to verify preview queue lifecycle or deployment topology | + +#### Start with the default test loop + +Start by triggering the consumer directly. That is usually the shortest path to proving retries, acknowledgements, and side effects like KV writes or database updates. + +If the queue is reached from an HTTP route, keep one route-level test too so the enqueue step itself stays visible. + +##### Example โ€” Testing a queue consumer through Devflare helpers + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, cf, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('queue consumer stores a processed result', async () => { + await cf.queue.trigger([ + { + id: 'job-1', + body: { id: 'task-1', type: 'process', createdAt: Date.now() } + } + ]) + + expect(await env.RESULTS.get('result:task-1')).not.toBeNull() +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `cf.queue.trigger()` when the consumer behavior is what you care about. +- Use `env.JOBS.send()` when you want to prove enqueue code in the same runtime path. +- Queue tests are a good place to assert retries or DLQ behavior because the helper already understands the message shape. + +#### When to move beyond the default harness + +##### Key points + +- Queue helper success does not automatically prove your preview or deploy queue topology is right. +- If the route-to-queue path matters, keep one request test so the enqueue boundary stays visible. +- Batch semantics and failure handling deserve their own tests instead of one giant everything-at-once assertion. + +> **Important โ€” Queue tests are allowed to be direct** +> +> You do not need to sneak queue behavior behind HTTP if the queue consumer itself is the thing you want confidence in. + +--- + +### Use Queues in a real application path + +> This starter example wires one producer, one consumer, and one stored result so you can see the whole queue loop without ceremony. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/queues/example`](/docs/bindings/queues/example) | +| Group | Bindings | +| Navigation title | Queues example | +| Eyebrow | Application example | + +A good queue example should prove three things quickly: the request can enqueue work, the consumer can process it, and some visible side effect confirms the work ran. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Explicit producer and consumer config | +| Runtime shape | Request enqueues work, queue handler stores result | +| Best use | Background jobs and post-request processing | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal queue config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'queue-example', + bindings: { + kv: { + RESULTS: 'results-kv' + }, + queues: { + producers: { + JOBS: 'jobs-queue' + }, + consumers: [ + { + queue: 'jobs-queue' + } + ] + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Queues path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Once this shape works, you can add retries, DLQs, and richer payloads without changing the fundamental loop. +- This example stays intentionally small so the queue contract is the thing you notice first. + +##### Example โ€” One fetch path and one queue consumer + +```ts +import { env } from 'devflare/runtime' +import type { MessageBatch } from '@cloudflare/workers-types' + +export async function fetch(): Promise { + await env.JOBS.send({ id: 'job-1', createdAt: Date.now() }) + return new Response('queued', { status: 202 }) +} + +export async function queue(batch: MessageBatch<{ id: string }>): Promise { + for (const message of batch.messages) { + await env.RESULTS.put('job:' + message.body.id, 'done') + message.ack() + } +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Explicit producer and consumer config. +- Runtime shape: Request enqueues work, queue handler stores result. +- Best use: Background jobs and post-request processing. + +> **Note โ€” Keep the first side effect visible** +> +> Writing one result record is a better first example than a complex job pipeline you cannot see end to end. + +--- + +### Use service bindings to keep multi-worker apps explicit instead of magical + +> The fast Devflare payoff is simple: wire one worker to another with `ref()`, call it through `env.MATH_SERVICE`, and prove the same relationship locally in one test. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/services`](/docs/bindings/services) | +| Group | Bindings | +| Navigation title | Services | +| Eyebrow | Binding reference | + +This is the clean lane for apps that genuinely need more than one worker. Devflare keeps the worker family explicit in config, resolves the referenced surface, and lets local tests use the same service binding contract instead of copied worker names or hand-built internal URLs. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.services` | +| Authoring shape | `Record \| ref().worker(...)` | +| Best for | Multi-worker systems, internal RPC boundaries, and explicit service composition | + +#### Add the binding to config + +The easiest honest starting point is one gateway worker, one referenced worker, and one service binding in config. + +`ref()` is especially useful because it keeps the dependency explicit while still giving Devflare enough structure to resolve, type, and boot the linked worker locally later. + +##### Example โ€” Service binding authoring with `ref()` + +```ts +import { defineConfig, ref } from 'devflare/config' + +const mathService = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + bindings: { + services: { + MATH_SERVICE: mathService.worker, + ADMIN: mathService.worker('AdminEntrypoint') + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Services path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Use the service in the gateway worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.MATH_SERVICE.add(4, 5) + return Response.json({ result }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Services application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local runtime and multi-worker tests. Start locally with `createTestContext()` plus `env.MY_SERVICE`; that lane should cover the normal Services application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Services details. + +#### When this binding fits best + +##### Key points + +- Use service bindings when another worker is a real dependency, not when one large worker is merely inconvenient to think about. +- They are a strong fit for internal APIs, admin surfaces, search workers, and explicit worker-family boundaries. +- If the dependency is actually shared data rather than another service boundary, a direct binding like D1, KV, or DO may stay simpler. + +#### Testing path + +##### Key points + +- Preview isolation follows resolved worker names, not just whatever branch or alias string you passed to a deploy command. +- Named entrypoints are modeled, but critical production wiring is still worth validating in compiled output. +- Service bindings are references, not preview-managed account resources like KV, D1, or queues. + +> **Note โ€” A very good review question** +> +> Ask which worker names a preview will actually deploy before you assume the worker family is isolated. + +#### Open the next page when you need it + +##### Highlights + +- **Services internals** โ€” Check emitted Wrangler `services`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/services/internals)) +- **Testing Services** โ€” Pick the `createTestContext()` plus `env.MY_SERVICE` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/services/testing)) +- **Services example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/services/example)) + +--- + +### How Devflare wires Services from config to runtime + +> Devflare resolves referenced worker configs, bundles the linked worker surfaces, and then exposes those services as local multi-worker bindings. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/services/internals`](/docs/bindings/services/internals) | +| Group | Bindings | +| Navigation title | Services internals | +| Eyebrow | Under the hood | + +Service bindings feel more than cosmetic: the tooling follows the relationship far enough to keep local tests, type generation, and compiled output aligned. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Plain objects and `ref().worker(...)` values normalize into one service-binding model | +| Compile target | Wrangler `services` | +| Preview note | Preview can rewrite service names, but service bindings are not preview-managed resources like KV or D1 | + +#### How authored config becomes Wrangler config + +Service bindings can be authored as plain binding objects or as `ref().worker(...)` results. Devflare normalizes those into one shape so compiler, type generation, and test setup can all reason about them consistently. + +When a binding comes from `ref()`, Devflare can follow the referenced config, discover the relevant worker surface, and keep that relationship visible in local tooling. + +##### Example โ€” Services config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig, ref } from 'devflare/config' + +const mathService = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + bindings: { + services: { + MATH_SERVICE: mathService.worker, + ADMIN: mathService.worker('AdminEntrypoint') + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "services": [ + { "binding": "MATH_SERVICE", "service": "math-service" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- `resolveServiceBindings()` is responsible for following referenced configs and bundling the default `worker.ts` export or named entrypoints as needed. +- Local multi-worker Miniflare wiring uses the resolved service metadata so a gateway worker can call another worker naturally in tests. +- Type generation can emit service-specific interfaces; if that is not possible, the binding falls back to a generic `Fetcher` contract. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits the standard `services` array that Wrangler expects. +- Preview flows can rewrite service names when the preview naming rules say they should, but there is no separate resource-provisioning lifecycle for services themselves. +- Critical production wiring is still worth checking through `config print`, `build`, or dry-run deploy output. + +> **Tip โ€” This is configuration as architecture, not just syntax** +> +> Service bindings work well in Devflare because the relationships are explicit enough for tooling to follow, type, and test. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Service bindings docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.services`. + +##### Highlights + +- **Cloudflare Service bindings docs** โ€” Platform reference for worker-to-worker bindings, service entrypoints, and the underlying runtime contract. ([link](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for worker-to-worker bindings, service entrypoints, and the underlying runtime contract. | How to author `bindings.services`, what the runtime surface looks like, and how Services fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Local runtime and multi-worker tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Services the way Devflare expects it to run + +> Service bindings are one of the clearest Devflare wins in multi-worker apps: you can keep the real worker boundary and still prove it through the default local harness. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/services/testing`](/docs/bindings/services/testing) | +| Group | Bindings | +| Navigation title | Testing Services | +| Eyebrow | Testing | + +Start with `createTestContext()`, then call the bound service through the generated env shape. That proves the config relationship, the local worker family, and the callable contract in the same language the app itself uses. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Gateway-to-service calls, entrypoint wiring, and typed multi-worker behavior | +| Default harness | `createTestContext()` plus `env.MY_SERVICE` | +| Escalate when | The risk is worker naming drift, preview topology, or compiled output correctness | + +#### Start with the default test loop + +The shortest honest test is usually one real service call through the generated env binding. That already proves the config relationship and the callable surface. + +Keep one test for the default worker entry and one for any named entrypoint that matters operationally. + +##### Example โ€” Testing a service binding through the env + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('service binding calls the default worker export', async () => { + expect(await env.MATH_SERVICE.add(5, 3)).toBe(8) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use the bound env service directly when the service relationship is the thing you want to prove. +- Keep named entrypoints explicit in tests so they do not quietly drift from the config contract. +- Run `devflare types` whenever service entrypoints change so env autocomplete and generated types stay in sync. + +#### When to move beyond the default harness + +##### Key points + +- Local tests prove the callable relationship, not that your preview or production worker names are what you intended. +- If the service graph is business-critical, validate compiled output before deploys as well. +- Test naming and topology at preview or build time when those are the real failure modes. + +> **Warning โ€” A typed local call is not the whole deploy story** +> +> The local harness tells you the relationship is modeled correctly. A preview or build check tells you the resolved worker names are still the ones you expect. + +--- + +### Use Services in a real application path + +> A real Services application path with config and runtime code kept side by side. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/services/example`](/docs/bindings/services/example) | +| Group | Bindings | +| Navigation title | Services example | +| Eyebrow | Application example | + +Use this as the application-focused Services example before you add feature-specific abstractions around the binding. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Explicit `ref()` wiring | +| Runtime shape | One env service call from the gateway worker | +| Best use | Internal APIs and worker-family boundaries | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Gateway config with a service ref + +```ts +import { defineConfig, ref } from 'devflare/config' + +const mathService = ref(() => import('../math-service/devflare.config')) + +export default defineConfig({ + name: 'gateway', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + MATH_SERVICE: mathService.worker + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Services path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Once this tiny path works, adding named entrypoints becomes an incremental extension, not a different architecture. +- Keep one simple service example like this around if you want a smoke check for multi-worker wiring. + +##### Example โ€” Use the service in the gateway worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.MATH_SERVICE.add(4, 5) + return Response.json({ result }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Explicit `ref()` wiring. +- Runtime shape: One env service call from the gateway worker. +- Best use: Internal APIs and worker-family boundaries. + +--- + +### Use the AI binding when the worker needs real Workers AI inference, not just a local mock + +> Devflare makes Workers AI usable by keeping the binding tiny in config, the worker call obvious, and the remote smoke test explicit instead of fake. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai`](/docs/bindings/ai) | +| Group | Bindings | +| Navigation title | AI | +| Eyebrow | Binding reference | + +AI is still remote-oriented, but the first useful path is simple: one worker route, one `env.AI.run(...)` call, and one skip-aware remote test that says clearly when the real platform was involved. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.ai` | +| Authoring shape | `{ binding: string }` | +| Best for | Real inference against Workers AI models | + +#### Add the binding to config + +AI is a remote-oriented binding, but the first worker path should still be tiny and concrete: receive one request, call one model, return one JSON response. + +The Devflare-specific win is not fake local inference. It is that config, worker code, and remote test gating stay explicit enough that you know when the real platform was actually exercised. + +##### Example โ€” Workers AI binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'ai-worker', + bindings: { + ai: { + binding: 'AI' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first AI path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” A tiny inference endpoint + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply with OK only.' }], + max_tokens: 4 + }) + + return Response.json({ result }) +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Remote-oriented; local tests require remote mode. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the ai call is expensive, flaky, or business-critical enough to need a separate release gate. This is the lane for full AI product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use AI when the worker should call a real Workers AI model. +- Keep the binding dedicated to model work instead of pretending every route needs AI by default. +- If the only goal is local happy-path UI wiring, use a normal fake at the app edge and reserve remote AI tests for the worker boundary. + +#### Testing path + +##### Key points + +- AI is remote-oriented, so local-only test runs should not be expected to exercise real inference. +- Cloudflare auth and a resolvable account are part of the contract for meaningful AI tests. An explicit `accountId` helps when the target account would otherwise be ambiguous, but it is not the only way Devflare can resolve one. +- Because inference has cost and availability implications, it deserves more deliberate test gating than local-first bindings. + +> **Warning โ€” Do not present AI as a local-first binding** +> +> The honest story is that Devflare supports the binding cleanly, but real AI behavior still requires remote infrastructure. + +#### Open the next page when you need it + +##### Highlights + +- **AI internals** โ€” Check emitted Wrangler `ai` binding, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/ai/internals)) +- **Testing AI** โ€” Pick the `createTestContext()` after remote mode is enabled, plus `shouldSkip.ai` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/ai/testing)) +- **AI example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/ai/example)) + +--- + +### How Devflare wires AI from config to runtime + +> AI has a smaller compiler story than storage bindings, but a more explicit auth and remote-runtime story. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai/internals`](/docs/bindings/ai/internals) | +| Group | Bindings | +| Navigation title | AI internals | +| Eyebrow | Under the hood | + +Devflare does not invent a fake local AI runtime. It compiles the binding, checks remote requirements when needed, and exposes remote helpers for tests that intentionally opt in. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | The authored shape is small, so the important complexity lives in auth and remote enablement rather than config normalization | +| Compile target | Wrangler `ai` binding | +| Preview note | AI is remote-oriented; preview is less about provisioning and more about whether the worker path may call the model | + +#### How authored config becomes Wrangler config + +AI does not need the same name-versus-id resolution dance as KV or D1. The authored shape is basically โ€œwhich env binding name should exist.โ€ + +The heavier implementation work lives in auth checks and remote-test setup, because the value of the binding only appears once the worker can reach real Cloudflare AI services. + +##### Example โ€” AI config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'ai-worker', + bindings: { + ai: { + binding: 'AI' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "ai": { + "binding": "AI" + } +} +``` + +#### What local runtime support covers + +##### Key points + +- `checkRemoteBindingRequirements()` treats AI as a binding that requires remote account context. +- `createTestContext()` can inject a remote AI helper when remote mode is enabled and an account can be resolved. +- `Ai.gateway()` is not supported by the current remote AI test helper, so gateway-specific flows need a higher-level integration path. +- `shouldSkip.ai` exists so tests can say clearly when remote inference is unavailable instead of failing opaquely. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits the AI binding shape directly into generated Wrangler output. +- Because the runtime behavior is remote-oriented, the major operational risk is not syntax โ€” it is auth, availability, and cost control. +- Preview behavior is mostly about whether that worker path should call real models, not about separate preview-managed AI resources. + +> **Note โ€” Honest tooling beats fake local magic** +> +> Devflare makes AI explicit and testable, but it does not pretend local emulation is equivalent to real inference. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Workers AI docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.ai`. + +##### Highlights + +- **Cloudflare Workers AI docs** โ€” Platform reference for model access, remote inference behavior, pricing, and account prerequisites. ([link](https://developers.cloudflare.com/workers-ai/configuration/bindings/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for model access, remote inference behavior, pricing, and account prerequisites. | How to author `bindings.ai`, what the runtime surface looks like, and how AI fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the real remote product behavior, account requirements, and runtime constraints on the platform. | Remote-oriented; local tests require remote mode. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test AI the way Devflare expects it to run + +> The right AI test strategy is selective: use remote mode when you mean to test inference, and skip cleanly when the environment is not allowed to do that. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai/testing`](/docs/bindings/ai/testing) | +| Group | Bindings | +| Navigation title | Testing AI | +| Eyebrow | Testing | + +Trying to force AI into the same local-only expectations as KV or D1 leads to misleading tests. Devflare already gives you the right gates โ€” use them. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Remote inference checks and binding-level AI smoke tests | +| Default harness | `createTestContext()` after remote mode is enabled, plus `shouldSkip.ai` | +| Escalate when | The AI call is expensive, flaky, or business-critical enough to need a separate release gate | + +#### Start with the default test loop + +Start with a tiny inference call and a tiny assertion. The goal is to prove that the binding works and the worker can talk to the intended model, not to test your entire AI product in one unit test. + +Enable remote mode first โ€” for example with `devflare remote enable ...` or `DEVFLARE_REMOTE=1` (or another truthy value) in automation โ€” and skip explicitly when the environment still cannot support remote AI instead of forcing the test to fail in noisy ways. + +##### Example โ€” A remote-oriented AI test + +```ts +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +const skipAI = await shouldSkip.ai + +describe.skipIf(skipAI)('AI binding', () => { + test('runs a tiny inference request', async () => { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply with OK only.' }], + max_tokens: 4 + }) + + expect(result).toBeDefined() + }) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Enable remote mode before expecting `createTestContext()` to inject a real AI binding, for example with `DEVFLARE_REMOTE=1` in automation. +- Use `shouldSkip.ai` to make remote prerequisites explicit in the test file itself. +- Keep AI assertions small enough that failures teach you about the binding path, not about prompt engineering drift. +- Use non-AI stubs above the worker layer when the app UI only needs a placeholder during purely local development. + +#### When to move beyond the default harness + +##### Key points + +- Remote AI tests are not free; keep them targeted and intentional. +- If the worker depends on `Ai.gateway()`, test that path outside the remote AI helper because the helper warns and does not implement gateway mode. +- If the worker contract is business-critical, move AI smoke tests into an explicit integration or release lane rather than running them everywhere. +- Do not confuse local app mocks with proof that the real AI binding path works. + +> **Important โ€” Skip is not weakness here** +> +> For remote bindings, a clear skip condition is often more trustworthy than a forced local pseudo-test that never exercised the real platform. + +--- + +### Use AI in a real application path + +> A real AI application path with config and runtime code kept side by side. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai/example`](/docs/bindings/ai/example) | +| Group | Bindings | +| Navigation title | AI example | +| Eyebrow | Application example | + +Use this as the application-focused AI example before you add feature-specific abstractions around the binding. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Minimal binding declaration | +| Runtime shape | Call `env.AI.run(...)` from the worker | +| Best use | Small inference endpoints and smoke checks | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal AI config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'ai-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + ai: { + binding: 'AI' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level AI path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Use a cheap, small model in smoke paths unless the point is to verify a specific expensive production model. +- Keep local app mocks above this worker route if you need offline UI development. + +##### Example โ€” A tiny inference endpoint + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const result = await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Reply with OK only.' }], + max_tokens: 4 + }) + + return Response.json({ result }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Minimal binding declaration. +- Runtime shape: Call `env.AI.run(...)` from the worker. +- Best use: Small inference endpoints and smoke checks. + +> **Important โ€” The Devflare win is the explicit remote gate** +> +> A clear skip condition is more trustworthy than a fake local AI emulator that never touched the real platform. That honesty is part of what makes the Devflare AI story usable. + +--- + +### Use Vectorize when the worker really owns similarity search, not just string matching + +> Devflare makes Vectorize usable by keeping the index name explicit in config, preview naming honest, and the real smoke test explicit instead of buried under mocks. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/vectorize`](/docs/bindings/vectorize) | +| Group | Bindings | +| Navigation title | Vectorize | +| Eyebrow | Binding reference | + +The right first path is small: one binding, one tiny upsert-and-query route, and one skip-aware remote smoke test that tells the truth about whether the real index was involved. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.vectorize` | +| Authoring shape | `Record` | +| Best for | Similarity search, embedding-backed lookup, and retrieval paths that belong in the worker | + +#### Add the binding to config + +Vectorize authoring is simple in config, but the operational story matters: an index must exist, dimensions must match, and tests should acknowledge that they are calling a real remote system. + +Devflare helps by keeping the binding explicit, the index name visible, and preview resource handling deliberate when the preview needs its own index. + +##### Example โ€” Vectorize binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + vectorize: { + DOCUMENT_INDEX: { + indexName: 'document-index' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Vectorize path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” A tiny write-and-query route + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const vector = Array(32).fill(0.5) + + await env.DOCUMENT_INDEX.upsert?.([ + { id: 'doc-1', values: vector, metadata: { title: 'Demo doc' } } + ]) + + const result = await env.DOCUMENT_INDEX.query?.(vector, { + topK: 1, + returnMetadata: true + }) + + return Response.json({ result }) +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Remote-oriented; local tests require remote mode or explicit mocks. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the index contract is business-critical enough to need explicit ci or release gating. This is the lane for full Vectorize product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use Vectorize when semantic similarity is part of the workerโ€™s real job, not when plain text search is already enough. +- It fits best when the worker is already producing or consuming embeddings as part of the application flow. +- If the vector store is optional or external to the worker, keep the boundary explicit and do not force Vectorize into every local path. + +#### Testing path + +##### Key points + +- Real Vectorize tests need remote access and an index that actually exists. +- Preview-scoped indexes are possible and lifecycle-managed, but they should be created only when the preview really needs isolated vector state. +- Local fake vector stores can be useful above the worker boundary, but they are not proof that the real binding path works. + +> **Warning โ€” Dimension and index setup are part of the contract** +> +> A passing unit test with a fake array is not equivalent to a real Vectorize call against the configured index. + +#### Open the next page when you need it + +##### Highlights + +- **Vectorize internals** โ€” Check emitted Wrangler `vectorize`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/vectorize/internals)) +- **Testing Vectorize** โ€” Pick the `createTestContext()` in remote mode plus `shouldSkip.vectorize` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/vectorize/testing)) +- **Vectorize example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/vectorize/example)) + +--- + +### How Devflare wires Vectorize from config to runtime + +> Vectorize compiles cleanly into Wrangler output and participates in preview resource lifecycle, but the runtime value of the binding mostly lives in remote infrastructure. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/vectorize/internals`](/docs/bindings/vectorize/internals) | +| Group | Bindings | +| Navigation title | Vectorize internals | +| Eyebrow | Under the hood | + +The codebase treats Vectorize as supported but remote-oriented. Config and preview handling are strong; local emulation is intentionally not oversold. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | The authored shape is small, so most complexity is in remote access and preview resource lifecycle | +| Compile target | Wrangler `vectorize` | +| Preview note | Preview-scoped Vectorize indexes are lifecycle-managed resources in Devflare | + +#### How authored config becomes Wrangler config + +Each Vectorize binding is a named env entry pointing to an explicit `indexName`. There is not much normalization complexity because the important value is already visible in source. + +The heavier internal story is around preview resource handling and remote test support, because that is where real index existence and lifecycle start to matter. + +##### Example โ€” Vectorize config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + vectorize: { + DOCUMENT_INDEX: { + indexName: 'document-index' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "vectorize": [ + { "binding": "DOCUMENT_INDEX", "index_name": "document-index" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- `createTestContext()` can supply a remote Vectorize binding when remote mode is enabled. +- The codebase uses `shouldSkip.vectorize` to make missing remote prerequisites explicit in tests. +- The exhaustive smoke app also uses mocks for some integration checks, which is fine as long as the docs do not confuse that with full local emulation. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits `index_name` into generated Wrangler-facing config. +- Preview resource lifecycle code can materialize branch-specific index names and later clean them up. +- Because the binding is remote-oriented, the hardest failures are usually missing indexes, dimension mismatches, or auth โ€” not config syntax. + +> **Note โ€” Supported does not mean locally emulated** +> +> Vectorize is fully part of the config schema and preview story, but the meaningful runtime path still belongs to the remote platform. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Vectorize docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.vectorize`. + +##### Highlights + +- **Cloudflare Vectorize docs** โ€” Platform reference for indexes, embeddings, remote querying, and preview-aware index lifecycle. ([link](https://developers.cloudflare.com/vectorize/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for indexes, embeddings, remote querying, and preview-aware index lifecycle. | How to author `bindings.vectorize`, what the runtime surface looks like, and how Vectorize fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the real remote product behavior, account requirements, and runtime constraints on the platform. | Remote-oriented; local tests require remote mode or explicit mocks. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Vectorize the way Devflare expects it to run + +> The right Vectorize tests are targeted remote checks: a small insert or query, a clear skip condition, and a real index behind the binding. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/vectorize/testing`](/docs/bindings/vectorize/testing) | +| Group | Bindings | +| Navigation title | Testing Vectorize | +| Eyebrow | Testing | + +Avoid pretending a local fake embedding store proved the same thing. It may still be useful for UI or higher-level app tests, but it is not the binding test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Remote similarity-search checks and index smoke tests | +| Default harness | `createTestContext()` in remote mode plus `shouldSkip.vectorize` | +| Escalate when | The index contract is business-critical enough to need explicit CI or release gating | + +#### Start with the default test loop + +Keep the test as small as possible: insert one vector or query one known embedding and verify the shape of the result. + +If the index is missing, skip with a clear message. That teaches future maintainers more than a mysterious failure ever will. + +##### Example โ€” A remote Vectorize smoke test + +```ts +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { createTestContext, env, shouldSkip } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +const skipVectorize = await shouldSkip.vectorize + +describe.skipIf(skipVectorize)('Vectorize binding', () => { + test('accepts one upsert and one query', async () => { + const vector = Array(32).fill(0.5) + await env.DOCUMENT_INDEX.upsert?.([ + { id: 'doc-1', values: vector, metadata: { kind: 'demo' } } + ]) + + const result = await env.DOCUMENT_INDEX.query?.(vector, { topK: 1 }) + expect(result).toBeDefined() + }) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `shouldSkip.vectorize` so missing remote prerequisites are explicit instead of noisy. +- Keep the vector size and index name close to the test so the contract remains visible. +- If the surrounding app only needs a demo path locally, mock above the worker boundary instead of pretending the remote index was exercised. + +#### When to move beyond the default harness + +##### Key points + +- Running Vectorize tests everywhere is rarely necessary; put them where the signal is worth the cost. +- A passing local mock tells you nothing about index existence or vector dimension compatibility. +- If a preview environment owns its own index, add one lifecycle-aware check for that path specifically. + +> **Important โ€” A tiny real query beats a giant fake suite** +> +> For remote vector search, one truthful remote smoke check is often worth more than a dozen intricate local fakes. + +--- + +### Use Vectorize in a real application path + +> A real Vectorize application path with config and runtime code kept side by side. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/vectorize/example`](/docs/bindings/vectorize/example) | +| Group | Bindings | +| Navigation title | Vectorize example | +| Eyebrow | Application example | + +That is enough to show the binding shape, the worker contract, and the Devflare remote gate without dragging in a whole retrieval stack on page one. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Explicit index naming | +| Runtime shape | Upsert one vector and query it back | +| Best use | Search prototypes and embedding-backed retrieval endpoints | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal Vectorize config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'vectorize-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + vectorize: { + DOCUMENT_INDEX: { + indexName: 'document-index' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Vectorize path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the embedding dimension explicit and consistent with the actual index you created. +- If you later split write and read into separate routes, this same example still teaches the core binding path. + +##### Example โ€” A tiny write-and-query route + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const vector = Array(32).fill(0.5) + + await env.DOCUMENT_INDEX.upsert?.([ + { id: 'doc-1', values: vector, metadata: { title: 'Demo doc' } } + ]) + + const result = await env.DOCUMENT_INDEX.query?.(vector, { + topK: 1, + returnMetadata: true + }) + + return Response.json({ result }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Explicit index naming. +- Runtime shape: Upsert one vector and query it back. +- Best use: Search prototypes and embedding-backed retrieval endpoints. + +--- + +### Use Hyperdrive when the worker needs a real PostgreSQL path behind Cloudflareโ€™s pooling layer + +> Hyperdrive is modeled in Devflare config, compile flows, local Miniflare wiring, and pure tests through explicit local connection strings. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/hyperdrive`](/docs/bindings/hyperdrive) | +| Group | Bindings | +| Navigation title | Hyperdrive | +| Eyebrow | Binding reference | + +For local work, point the binding at a local or test PostgreSQL database. Cloudflare still owns the hosted pooling layer, placement, account credentials, and production routing. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.hyperdrive` | +| Authoring shape | `Record` | +| Best for | Workers that connect to PostgreSQL through Hyperdrive | + +#### Add the binding to config + +Hyperdrive follows the same stable-name instinct as KV and D1: author a readable name in source when you can, then let Devflare resolve ids later when a flow actually needs them. + +Add `localConnectionString` when local dev or tests should query a local database without contacting Cloudflare. + +##### Example โ€” Hyperdrive binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'postgres-worker', + bindings: { + hyperdrive: { + DB: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + ANALYTICS_DB: { id: 'hyperdrive-id' } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Hyperdrive path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Read one product through Hyperdrive + +```ts +import postgres from 'postgres' +import { env, getFetchEvent } from 'devflare/runtime' + +const sql = postgres(env.DB.connectionString) + +export async function fetch(): Promise { + const event = getFetchEvent() + const slug = new URL(event.request.url).searchParams.get('slug') ?? 'starter-kit' + const [product] = await sql.unsafe( + 'select slug, name, price_cents from products where slug = $1 limit 1', + [slug] + ) + + return product ? Response.json(product) : new Response('not found', { status: 404 }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Hyperdrive application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support when Devflare has a local database connection string for the binding. Start locally with `createTestContext()` or `createOfflineEnv()` with `localConnectionString`; that lane should cover the normal Hyperdrive application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Hyperdrive details. + +#### When this binding fits best + +##### Key points + +- Use Hyperdrive when the worker needs PostgreSQL and you want the Cloudflare-managed connection path rather than raw direct wiring. +- It fits best when a real Postgres database already exists and the worker boundary should speak to it deliberately. +- If your data is already a comfortable fit for D1, D1 may still be the simpler first choice. + +#### Testing path + +##### Key points + +- Use `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_DB` to override the configured local connection string in CI or per-developer shells. +- Preview-scoped Hyperdrive configs are not auto-cloned from the base configuration because stored credentials are not always available for that. +- When a preview Hyperdrive config does not exist, Devflare may fall back to the base configuration and warn. + +> **Warning โ€” Local and hosted responsibilities are different** +> +> Devflare can wire the local database path. Cloudflare still owns hosted pooling, production credentials, placement, billing, and account state. + +#### Open the next page when you need it + +##### Highlights + +- **Hyperdrive internals** โ€” Check emitted Wrangler `hyperdrive`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/hyperdrive/internals)) +- **Testing Hyperdrive** โ€” Pick the `createTestContext()` or `createOfflineEnv()` with `localConnectionString` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/hyperdrive/testing)) +- **Hyperdrive example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/hyperdrive/example)) + +--- + +### How Devflare wires Hyperdrive from config to runtime + +> Hyperdrive uses the same normalize-and-resolve pattern as KV and D1, and local runtime config is driven by the binding connection string. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/hyperdrive/internals`](/docs/bindings/hyperdrive/internals) | +| Group | Bindings | +| Navigation title | Hyperdrive internals | +| Eyebrow | Under the hood | + +That fallback behavior is worth documenting explicitly because it changes how you should think about preview isolation and cleanup for database-backed flows. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Hyperdrive follows the same name-versus-id normalization family as KV and D1 | +| Compile target | Wrangler `hyperdrive` | +| Preview note | Preview Hyperdrive configs may fall back to the base config when a preview clone cannot be materialized | + +#### How authored config becomes Wrangler config + +Hyperdrive authoring accepts a string, `{ name }`, or `{ id }`, and Devflare normalizes those into one internal binding shape so later code can treat them consistently. + +That part looks familiar if you already understand KV or D1. The unusual part is preview lifecycle, not the authored schema. + +##### Example โ€” Hyperdrive config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'postgres-worker', + bindings: { + hyperdrive: { + DB: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + ANALYTICS_DB: { id: 'hyperdrive-id' } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "hyperdrive": [ + { "binding": "DB", "id": "hyperdrive-id" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Devflare passes `bindings.hyperdrive.*.localConnectionString` into Miniflare `hyperdrives` so local Worker code can use the normal Hyperdrive binding shape. +- `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_` and the legacy `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_` override config for local runs. +- Pure tests can use `createOfflineEnv()` or `createMockHyperdrive()` when the application code only needs the connection string. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Build and deploy resolve name-based Hyperdrive bindings to real configuration ids before generating output. +- Preview resource logic cannot always clone a base Hyperdrive config because Cloudflare does not expose stored credentials for that workflow. +- When a preview Hyperdrive config is missing but the base config exists, Devflare can fall back to the base binding and warn instead of pretending isolation happened. + +> **Note โ€” This is a lifecycle caveat, not a syntax caveat** +> +> The config shape is straightforward. The reason Hyperdrive needs extra documentation is the preview and credential story, not the authoring syntax. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Hyperdrive docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.hyperdrive`. + +##### Highlights + +- **Cloudflare Hyperdrive docs** โ€” Platform reference for database acceleration, connection strings, limits, and supported databases. ([link](https://developers.cloudflare.com/hyperdrive/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for database acceleration, connection strings, limits, and supported databases. | How to author `bindings.hyperdrive`, what the runtime surface looks like, and how Hyperdrive fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support when Devflare has a local database connection string for the binding. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Hyperdrive the way Devflare expects it to run + +> Hyperdrive testing should start with a local connection string, then add a focused query test against the database shape your app actually uses. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/hyperdrive/testing`](/docs/bindings/hyperdrive/testing) | +| Group | Bindings | +| Navigation title | Testing Hyperdrive | +| Eyebrow | Testing | + +Devflare can provide the binding locally. Your test database still has to exist, just like any other local Postgres dependency. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Local PostgreSQL integration paths and connection-string-driven app code | +| Default harness | `createTestContext()` or `createOfflineEnv()` with `localConnectionString` | +| Escalate when | The app depends on real preview isolation or actual Postgres query behavior | + +#### Start with the default test loop + +Start with one small assertion that the binding exposes the local connection string your database client expects. + +Then add focused integration tests against the actual local database path instead of involving Cloudflare for application-owned SQL behavior. + +##### Example โ€” A conservative Hyperdrive smoke test + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('Hyperdrive binding exposes local connection info', () => { + expect(env.DB).toBeDefined() + expect(env.DB.connectionString).toContain('localhost') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `localConnectionString` in config when the test should run without Cloudflare. +- Use `createMockHyperdrive` when a pure unit test needs a Hyperdrive-shaped binding without Miniflare. +- Keep one higher-level integration path for the real database behavior you actually care about. +- If preview isolation matters, test the fallback or dedicated preview strategy explicitly. + +#### When to move beyond the default harness + +##### Key points + +- Do not run local Hyperdrive tests against a shared production database. +- If the worker truly depends on live query behavior, prefer an integration test against a real database path. +- Preview-specific Hyperdrive expectations deserve a dedicated test because automatic cloning is not guaranteed. + +> **Warning โ€” Keep database ownership explicit** +> +> Devflare owns the binding wiring; the test suite owns the local database lifecycle and seed data. + +--- + +### Use Hyperdrive in a real application path + +> This example uses Hyperdrive in an application route that reads one product from PostgreSQL. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/hyperdrive/example`](/docs/bindings/hyperdrive/example) | +| Group | Bindings | +| Navigation title | Hyperdrive example | +| Eyebrow | Application example | + +Use the same route locally with a local Postgres connection string, then deploy with the Cloudflare Hyperdrive configuration id or name. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Stable Hyperdrive naming | +| Runtime shape | Query through `env.DB.connectionString` | +| Best use | Product, order, account, or tenant data stored in PostgreSQL | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal Hyperdrive config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hyperdrive-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + hyperdrive: { + DB: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Hyperdrive path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Install the client with `bun add postgres` and point `localConnectionString` at a local or CI Postgres database. + +##### Example โ€” Read one product through Hyperdrive + +```ts +import postgres from 'postgres' +import { env, getFetchEvent } from 'devflare/runtime' + +const sql = postgres(env.DB.connectionString) + +export async function fetch(): Promise { + const event = getFetchEvent() + const slug = new URL(event.request.url).searchParams.get('slug') ?? 'starter-kit' + const [product] = await sql.unsafe( + 'select slug, name, price_cents from products where slug = $1 limit 1', + [slug] + ) + + return product ? Response.json(product) : new Response('not found', { status: 404 }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Stable Hyperdrive naming. +- Runtime shape: Query through `env.DB.connectionString`. +- Best use: Product, order, account, or tenant data stored in PostgreSQL. + +> **Note โ€” Use a real local database** +> +> Hyperdrive local support means Devflare can pass the connection path through the binding. It does not create or seed PostgreSQL for you. + +--- + +### Use Browser Rendering when the worker really needs a headless browser path + +> Browser Rendering shines in Devflareโ€™s bridge-backed dev story: keep one browser binding, one narrow worker route, and one smoke path that proves launch works. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/browser-rendering`](/docs/bindings/browser-rendering) | +| Group | Bindings | +| Navigation title | Browser Rendering | +| Eyebrow | Binding reference | + +The platform limit is still real โ€” exactly one browser binding โ€” but Devflare adds the missing local ergonomics through the browser shim, binding worker, and integration-friendly route model. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.browser` | +| Authoring shape | `Record with exactly one entry` | +| Best for | PDF generation, screenshots, and other worker-side headless browser tasks | + +#### Add the binding to config + +Browser Rendering looks a little unusual in config because the current contract is a named map with exactly one entry. The env key matters more than the configured string value that appears beside it. + +That is also why generated env typing stays conservative today: `devflare types` can model the binding as `Fetcher`, while the richer browser behavior comes from the dev server shim and browser-aware libraries. + +That single-binding constraint is not a Devflare whim. It reflects the current Wrangler and platform support Devflare is choosing to expose accurately. + +##### Example โ€” Browser binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'browser-worker', + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Browser Rendering path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Read one page title with Puppeteer + +```ts +import puppeteer from '@cloudflare/puppeteer' +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const browser = await puppeteer.launch(env.BROWSER as Parameters[0]) + + try { + const page = await browser.newPage() + await page.goto('https://example.com/', { waitUntil: 'load' }) + return Response.json({ title: await page.title() }) + } finally { + await browser.close() + } +} +``` + +#### Local and Remote Support + +Devflare can run useful Browser Rendering application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Local support is the Devflare browser-rendering shim: the dev server starts a loopback-only browser bridge and binding worker that browser libraries can call during local development. Treat it as a practical local/dev path, then use Cloudflare for hosted Browser Rendering limits, session behavior, and product fidelity. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Browser Rendering details. + +#### When this binding fits best + +##### Key points + +- Use Browser Rendering when the worker truly needs a browser โ€” for PDF generation, screenshots, or browser-like page evaluation. +- Keep browser usage narrow and explicit because browser work is usually heavier than normal request handling. +- If a feature can be expressed as a plain fetch or HTML transform, it probably should be. + +#### Testing path + +##### Key points + +- Only one browser binding is currently supported. +- The strongest local story lives in dev-server and integration flows, not in a rich browser-specific test helper API. +- Preview naming exists, but browser resources are not provisioned or deleted like account-managed storage resources. + +> **Warning โ€” Exactly one really means one** +> +> If you configure more than one browser binding, schema validation rejects it because the underlying Wrangler contract only supports one. + +#### Open the next page when you need it + +##### Highlights + +- **Browser Rendering internals** โ€” Check emitted Wrangler `browser` binding, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/browser-rendering/internals)) +- **Testing Browser Rendering** โ€” Pick the A narrow browser route exercised through the dev server, a preview URL, or another integration-style path path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/browser-rendering/testing)) +- **Browser Rendering example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/browser-rendering/example)) + +--- + +### How Devflare wires Browser Rendering from config to runtime + +> Browser Rendering support in Devflare is more than a config pass-through: the dev server starts a browser shim and a binding worker that line up with Cloudflare and puppeteer expectations. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/browser-rendering/internals`](/docs/bindings/browser-rendering/internals) | +| Group | Bindings | +| Navigation title | Browser Rendering internals | +| Eyebrow | Under the hood | + +That implementation detail is why the binding belongs in the docs library even though the test helper surface is narrower. There is real, deliberate runtime support here. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | The env binding name is the important authoring value, while the configured string is mainly used for naming and preview materialization | +| Compile target | Wrangler `browser` binding | +| Preview note | Preview can materialize the binding name, but browser resources are not lifecycle-managed account resources | + +#### How authored config becomes Wrangler config + +The browser binding schema accepts a record but then validates that only one key exists. Devflare treats that key as the meaningful env binding name and compiles it into the single `browser.binding` entry Wrangler expects. + +Emphasize the env key and the single-binding limit rather than implying the string value behaves like a normal bucket or namespace resource. + +##### Example โ€” Browser Rendering config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'browser-worker', + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "browser": { + "binding": "BROWSER" + } +} +``` + +#### What local runtime support covers + +##### Key points + +- The dev server starts a browser shim that can install Chrome Headless Shell and proxy the Browser Rendering protocol over HTTP and WebSocket. +- The binding worker exists so browser libraries like `@cloudflare/puppeteer` can talk to the expected Worker-side contract. +- Generated env typing stays conservative here too: the binding currently lands as `Fetcher`, which is another reason to keep the worker-facing browser path narrow and explicit. +- This is why browser local support feels more like dev-server infrastructure than like a small `cf.browser.*` helper. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits the single browser binding from the configured env key. +- Preview logic can materialize names, but Devflare does not provision or delete browser โ€œresourcesโ€ because they are not account-managed the same way storage bindings are. +- The browser path can also warn about missing local WebSocket support when the environment lacks the `ws` dependency needed for proxying. + +> **Note โ€” Local browser-rendering shim** +> +> The dev-side endpoint Devflare exposes for `@cloudflare/puppeteer` is the **local browser-rendering shim**. It accepts only loopback browser origins (e.g. `http://127.0.0.1:*`, `http://localhost:*`) plus origin-less tool traffic such as Puppeteer or curl. +> +> This loopback-only posture is the security model of the shim itself โ€” it is devflareโ€™s protected helper endpoint for the local Browser Rendering binding. It is **not** a policy applied to your normal worker routes; user app routes still follow whatever request and CORS rules the worker code itself defines. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Browser Rendering docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.browser`. + +##### Highlights + +- **Cloudflare Browser Rendering docs** โ€” Platform reference for browser sessions, quick actions, automation limits, and integration methods. ([link](https://developers.cloudflare.com/browser-rendering/workers-bindings/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for browser sessions, quick actions, automation limits, and integration methods. | How to author `bindings.browser`, what the runtime surface looks like, and how Browser Rendering fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Supported, but the strongest story is dev server and integration rather than a dedicated test helper. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Browser Rendering the way Devflare expects it to run + +> Browser tests should usually be integration-flavored: either drive the worker in dev or exercise a thin smoke path that proves the binding can launch and fetch. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/browser-rendering/testing`](/docs/bindings/browser-rendering/testing) | +| Group | Bindings | +| Navigation title | Testing Browser Rendering | +| Eyebrow | Testing | + +That is more truthful than pretending the browser binding has the same helper depth as `cf.queue.trigger()` or `env.DB.prepare()`. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Launch smoke tests, PDF generation routes, and browser-backed worker endpoints | +| Default harness | A narrow browser route exercised through the dev server, a preview URL, or another integration-style path | +| Escalate when | A real browser workflow is mission-critical or too heavy for ordinary test runs | + +#### Start with the default test loop + +Keep the worker-side browser entry small enough that one smoke path can prove it launches, opens a page, or returns a generated artifact. + +If the real logic is bigger โ€” for example a full PDF renderer DO โ€” write one narrow end-to-end check and keep the rest of the code tested at smaller layers. + +##### Example โ€” A tiny dev-server browser smoke check + +```ts +import { expect, test } from 'bun:test' + +const baseUrl = process.env.DEVFLARE_TEST_URL ?? 'http://127.0.0.1:8787' + +test('browser-backed route responds', async () => { + const response = await fetch(new URL('/browser-health', baseUrl)) + expect(response.ok).toBe(true) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Prefer one narrow worker route or DO method for browser tasks so the binding path stays testable. +- Drive that route through the dev server, a preview URL, or another integration path when browser launch itself is the thing under test. +- If you want Bun-only unit tests, stub above the browser boundary instead of expecting `createTestContext()` to create a browser binding for you. +- Treat browser local checks as smoke tests unless the app really needs a heavier dedicated lane. + +#### When to move beyond the default harness + +##### Key points + +- No dedicated browser helper surface means you should test the worker boundary or integration path instead of reaching for fictional convenience APIs. +- `createTestContext()` is still useful around surrounding worker code, but it is not a browser-specific helper that automatically populates `env.BROWSER` for you. +- Browser workloads are heavier than typical request tests, so they deserve intentional scheduling in CI. +- If the route depends on browser proxying or WebSockets, test that path in an environment close to the real dev server. + +> **Important โ€” Smoke test the launch path, not the whole internet** +> +> Browser bindings get expensive fast. One honest launch or render smoke path is usually better than an enormous browser suite that nobody trusts. + +--- + +### Use Browser Rendering in a real application path + +> This example shows the real browser path people actually need: one binding, one title-read route, and one smoke check through the dev server. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/browser-rendering/example`](/docs/bindings/browser-rendering/example) | +| Group | Bindings | +| Navigation title | Browser Rendering example | +| Eyebrow | Application example | + +It is intentionally smaller than a full PDF pipeline, but it uses the same Devflare idea: a narrow worker route on top of a bridge-backed local browser lane. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Single browser binding | +| Runtime shape | Launch puppeteer with the Worker binding and close it cleanly | +| Best use | Small screenshot, title-read, or PDF-generation entrypoints | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal browser config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'browser-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + browser: { + BROWSER: 'browser-resource' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Browser Rendering path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first route tiny so launch, navigation, and cleanup are the only moving parts you have to trust. +- If the real feature is PDF generation, this same pattern is the foundation for that worker path. + +##### Example โ€” Read one page title with Puppeteer + +```ts +import puppeteer from '@cloudflare/puppeteer' +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const browser = await puppeteer.launch(env.BROWSER as Parameters[0]) + + try { + const page = await browser.newPage() + await page.goto('https://example.com/', { waitUntil: 'load' }) + return Response.json({ title: await page.title() }) + } finally { + await browser.close() + } +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Single browser binding. +- Runtime shape: Launch puppeteer with the Worker binding and close it cleanly. +- Best use: Small screenshot, title-read, or PDF-generation entrypoints. + +> **Important โ€” The Devflare value is the bridge-backed local lane** +> +> Browser work is still heavier than most bindings, but Devflare gives it a real local/dev story instead of forcing you to document only the production path. Keep the first route narrow enough that launch failures are easy to diagnose. + +--- + +### Use Analytics Engine when the worker should write structured event points, not improvise log transport + +> Analytics Engine is modeled cleanly in Devflare config and generated types, but the repo evidence points to a lighter local story than KV, D1, and R2. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/analytics-engine`](/docs/bindings/analytics-engine) | +| Group | Bindings | +| Navigation title | Analytics Engine | +| Eyebrow | Binding reference | + +That usually means two good habits: keep the write path simple in the worker, and test the event-producing behavior through a thin boundary rather than by inventing a giant analytics simulation. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.analyticsEngine` | +| Authoring shape | `Record` | +| Best for | Structured analytics or event logging inside worker code | + +#### Add the binding to config + +The Analytics Engine binding is conceptually simple: pick a dataset name and write data points to it from the worker path that owns the event. + +What matters more than the config shape is resisting the urge to build a fake analytics platform around it just to write the first tests. + +##### Example โ€” Analytics Engine binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'analytics-worker', + bindings: { + analyticsEngine: { + APP_ANALYTICS: { + dataset: 'app-analytics' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Analytics Engine path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Write one analytics point in the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + env.APP_ANALYTICS.writeDataPoint({ + indexes: ['search'], + blobs: ['devflare query'] + }) + + return new Response('recorded') +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Supported, but usually tested through integration or thin mocks. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when analytics delivery itself is a release-critical guarantee. This is the lane for full Analytics Engine product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use Analytics Engine when the worker should record structured event points as part of handling real traffic or jobs. +- Keep analytics writes narrow and explicit so they stay easy to review. +- If the data is really application state, it probably belongs in D1 or another durable store instead of analytics. + +#### Testing path + +##### Key points + +- The repo does not show a dedicated analytics helper surface comparable to `cf.queue.trigger()` or `env.DB.prepare()`. +- Preview-scoped dataset names can be materialized, but Devflare does not provision or delete datasets because Analytics Engine creates them on first write. +- Tests should focus on event-producing behavior rather than pretending you need a full local analytics backend. + +> **Note โ€” This binding is about a write path** +> +> Document the write contract clearly and keep the testing story light. That is more useful than inventing an elaborate fake dataset universe. + +#### Open the next page when you need it + +##### Highlights + +- **Analytics Engine internals** โ€” Check emitted Wrangler `analytics_engine_datasets`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/analytics-engine/internals)) +- **Testing Analytics Engine** โ€” Pick the A thin worker test or explicit mock around `writeDataPoint()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/analytics-engine/testing)) +- **Analytics Engine example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/analytics-engine/example)) + +--- + +### How Devflare wires Analytics Engine from config to runtime + +> Analytics Engine has a straightforward compiler story, plus a preview note that matters because datasets are auto-created on first write instead of provisioned like buckets or databases. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/analytics-engine/internals`](/docs/bindings/analytics-engine/internals) | +| Group | Bindings | +| Navigation title | Analytics Engine internals | +| Eyebrow | Under the hood | + +That is the core reason the docs should separate it from storage bindings: the worker env shape is familiar, but the resource lifecycle behaves differently. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | The authored shape is a simple dataset mapping; the interesting behavior is lifecycle, not deep normalization | +| Compile target | Wrangler `analytics_engine_datasets` | +| Preview note | Preview names can change, but Devflare does not provision or delete Analytics Engine datasets for you | + +#### How authored config becomes Wrangler config + +Analytics Engine bindings are a small schema surface: a binding name maps to a dataset name. That keeps authored config simple and predictable. + +The more important implementation detail is that datasets are not managed like KV namespaces or buckets. They come to life on write, so preview lifecycle support looks different. + +##### Example โ€” Analytics Engine config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'analytics-worker', + bindings: { + analyticsEngine: { + APP_ANALYTICS: { + dataset: 'app-analytics' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "analytics_engine_datasets": [ + { "binding": "APP_ANALYTICS", "dataset": "app-analytics" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- The repo smoke app and integration tests show `writeDataPoint()` being called through the binding, which is enough to describe the runtime contract. +- There is no dedicated analytics helper surface in the test harness โ€” use thin worker tests or explicit mocks instead. +- Type generation still matters here because it keeps the env contract clear even when the test story is lighter. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile emits dataset entries into Wrangler-facing output. +- Preview materialization can rewrite dataset names, but Devflare intentionally does not try to provision or delete those datasets for you. +- That lifecycle difference is the main caveat compared with storage or queue resources. + +> **Warning โ€” Name changes do not imply resource management** +> +> Preview-scoped naming is useful, but it does not mean Devflare owns the full dataset lifecycle the way it can for KV, D1, or queues. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Workers Analytics Engine docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.analyticsEngine`. + +##### Highlights + +- **Cloudflare Workers Analytics Engine docs** โ€” Platform reference for write APIs, SQL querying, analytics ingestion patterns, and product limits. ([link](https://developers.cloudflare.com/analytics/analytics-engine/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for write APIs, SQL querying, analytics ingestion patterns, and product limits. | How to author `bindings.analyticsEngine`, what the runtime surface looks like, and how Analytics Engine fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Supported, but usually tested through integration or thin mocks. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Analytics Engine the way Devflare expects it to run + +> Analytics Engine tests should stay thin: verify that the worker writes a data point, not that you can recreate Cloudflare analytics locally. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/analytics-engine/testing`](/docs/bindings/analytics-engine/testing) | +| Group | Bindings | +| Navigation title | Testing Analytics Engine | +| Eyebrow | Testing | + +The repo evidence supports that approach. There are examples and smoke checks, but not a big dedicated analytics test harness pretending to be the platform. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Event-write smoke tests and worker behavior that should emit analytics | +| Default harness | A thin worker test or explicit mock around `writeDataPoint()` | +| Escalate when | Analytics delivery itself is a release-critical guarantee | + +#### Start with the default test loop + +The best default is a small test proving the worker attempted the analytics write when the expected request or job happened. + +If you later need stronger end-to-end confidence, add a higher-level integration or smoke lane instead of bloating the ordinary unit path. + +##### Example โ€” A thin analytics smoke check + +```ts +import { expect, test } from 'bun:test' + +const writes: unknown[] = [] +const analytics = { + writeDataPoint(point: unknown) { + writes.push(point) + } +} + +test('records an analytics point', () => { + analytics.writeDataPoint({ indexes: ['search'], blobs: ['devflare'] }) + expect(writes).toHaveLength(1) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Keep analytics writes behind a small helper if that makes them easier to assert in application-level tests. +- Use worker smoke tests around the route or job that should emit the event when you want stronger evidence than a tiny mock. +- Do not confuse โ€œwe called writeDataPointโ€ with โ€œthe whole reporting stack is perfectโ€ unless you added a real integration path for that. + +#### When to move beyond the default harness + +##### Key points + +- The ordinary docs should not imply that Devflare ships a full local Analytics Engine simulator. +- If analytics delivery is business-critical, put it in a dedicated smoke or release lane instead of overfitting every local test. +- Preview dataset names may differ, so if that matters operationally, test the generated naming separately. + +> **Important โ€” Thin and explicit wins here too** +> +> Analytics bindings are easiest to trust when the worker writes a clearly reviewable point and the tests prove that narrow behavior directly. + +--- + +### Use Analytics Engine in a real application path + +> This example writes one analytics event from one route, which is usually all you need to teach the binding shape clearly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/analytics-engine/example`](/docs/bindings/analytics-engine/example) | +| Group | Bindings | +| Navigation title | Analytics Engine example | +| Eyebrow | Application example | + +It keeps the dataset name visible, the event payload small, and the worker boundary obvious. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Explicit dataset naming | +| Runtime shape | Call `writeDataPoint()` during a request | +| Best use | Search analytics, request logging, and event emission | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal Analytics Engine config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'analytics-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + analyticsEngine: { + APP_ANALYTICS: { + dataset: 'app-analytics' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Analytics Engine path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the event payload small and explicit so you can reason about what the worker is writing. +- If the real event shape grows richer later, this tiny route still teaches the binding contract. + +##### Example โ€” Write one analytics point in the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + env.APP_ANALYTICS.writeDataPoint({ + indexes: ['search'], + blobs: ['devflare query'] + }) + + return new Response('recorded') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Explicit dataset naming. +- Runtime shape: Call `writeDataPoint()` during a request. +- Best use: Search analytics, request logging, and event emission. + +> **Note โ€” A route can teach the whole binding** +> +> For Analytics Engine, one request that writes one point is already enough to teach the env shape and the operational habit. + +--- + +### Use Send Email when the worker should send outbound email with explicit address rules + +> Send Email is a real binding surface in Devflare, and it is worth documenting separately from inbound `src/email.ts` handlers so the two flows do not get blurred together. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/send-email`](/docs/bindings/send-email) | +| Group | Bindings | +| Navigation title | Send Email | +| Eyebrow | Binding reference | + +That distinction matters because outbound email is a binding you call from worker code, while inbound email handling is a worker event surface with its own test helper story. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.sendEmail` | +| Authoring shape | `Record` | +| Best for | Outbound notification email and controlled email-sending paths from worker code | + +#### Add the binding to config + +Send Email bindings are easiest to trust when the allowed addresses are visible in config rather than buried in some last-minute secret or helper wrapper. + +Devflare validates the main mutual-exclusion rule here too: use either one `destinationAddress` or a list of `allowedDestinationAddresses`, not both. + +##### Example โ€” Send Email binding authoring + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'email-worker', + bindings: { + sendEmail: { + TRANSACTIONAL_EMAIL: { + allowedDestinationAddresses: ['ops@example.com'], + allowedSenderAddresses: ['noreply@example.com'] + }, + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Send Email path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Send one email from the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.SUPPORT_EMAIL.send({ + from: 'noreply@example.com', + to: 'support@example.com', + subject: 'New support request', + text: 'A customer asked for help.' + }) + + return new Response('sent') +} +``` + +#### Local and Remote Support + +Devflare can run useful Send Email application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Outbound local support; distinct from inbound email event testing. Start locally with `createTestContext()` plus `env.TRANSACTIONAL_EMAIL.send(...)`; that lane should cover the normal Send Email application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Send Email details. + +#### When this binding fits best + +##### Key points + +- Use Send Email when the worker needs to send notifications or transactional messages outward. +- Keep address restrictions explicit so the worker cannot quietly send anywhere it pleases. +- Do not confuse outbound send-email bindings with inbound email processing handlers. + +#### Testing path + +##### Key points + +- `destinationAddress` and `allowedDestinationAddresses` are mutually exclusive in one binding definition. +- The local story for outbound email is strong, but it should still be documented separately from inbound email event helpers. +- Preview resource lifecycle does not manage email addresses the way it manages storage resources, because the binding compiles the address rules as-is. + +> **Warning โ€” Outbound is not inbound** +> +> `env.TRANSACTIONAL_EMAIL.send(...)` and `src/email.ts` handler tests are connected by the domain, but they are different contracts and should be documented that way. + +#### Open the next page when you need it + +##### Highlights + +- **Send Email internals** โ€” Check emitted Wrangler `send_email`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/send-email/internals)) +- **Testing Send Email** โ€” Pick the `createTestContext()` plus `env.TRANSACTIONAL_EMAIL.send(...)` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/send-email/testing)) +- **Send Email example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/send-email/example)) + +--- + +### How Devflare wires Send Email from config to runtime + +> Send Email compiles into Wrangler output, normalizes message input at runtime, and supports local address restrictions instead of treating email as an unbounded free-for-all. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/send-email/internals`](/docs/bindings/send-email/internals) | +| Group | Bindings | +| Navigation title | Send Email internals | +| Eyebrow | Under the hood | + +That runtime normalization is worth calling out because it lets worker code send higher-level message shapes while Devflare translates them into the lower-level form the email path needs. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | The schema normalizes address restrictions and runtime message helpers normalize composed email input | +| Compile target | Wrangler `send_email` | +| Preview note | Address rules compile as authored; there is no separate preview resource lifecycle for email destinations | + +#### How authored config becomes Wrangler config + +The schema work here is less about ids and more about safety rules: which addresses are permitted and which combinations are invalid. + +At runtime, Devflare can normalize higher-level email message shapes into raw MIME-backed delivery when the outbound path needs it. + +##### Example โ€” Send Email config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'email-worker', + bindings: { + sendEmail: { + TRANSACTIONAL_EMAIL: { + allowedDestinationAddresses: ['ops@example.com'], + allowedSenderAddresses: ['noreply@example.com'] + }, + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "send_email": [ + { "name": "SUPPORT_EMAIL", "destination_address": "support@example.com" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Local send-email bindings can be created and enforced in the default runtime/test context. +- Address restrictions are part of the local contract, which keeps the binding honest during development. +- Inbound email helper APIs exist too, but they serve the inbound event story rather than replacing outbound bindings. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Compile turns the authored send-email rules into Wrangler-facing `send_email` entries. +- The binding rules are emitted as-is; there is no preview resource provisioning story for destination addresses or sender allow-lists. +- The runtime normalization step is the subtle part worth documenting because it shapes how friendly outbound code can look. + +> **Note โ€” Safety rules are part of the binding** +> +> The point of the schema is not only to make email possible. It is also to keep where the worker may send email visible and reviewable. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare send_email binding docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.sendEmail`. + +##### Highlights + +- **Cloudflare send_email binding docs** โ€” Platform reference for send_email binding restrictions, verified destinations, and Email Workers setup. ([link](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for send_email binding restrictions, verified destinations, and Email Workers setup. | How to author `bindings.sendEmail`, what the runtime surface looks like, and how Send Email fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Outbound local support; distinct from inbound email event testing. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Send Email the way Devflare expects it to run + +> Send Email is stronger locally than many platform-service bindings because outbound email can be exercised in the default harness, while inbound email has its own related helper surface. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/send-email/testing`](/docs/bindings/send-email/testing) | +| Group | Bindings | +| Navigation title | Testing Send Email | +| Eyebrow | Testing | + +That means the docs should teach both the outbound binding test and the conceptual split from inbound email event tests, so people do not mix the two up. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Outbound notification checks and address-restriction behavior | +| Default harness | `createTestContext()` plus `env.TRANSACTIONAL_EMAIL.send(...)` | +| Escalate when | The system has external email delivery requirements beyond the local binding path | + +#### Start with the default test loop + +Start with one direct outbound send call through the binding and verify the success or allow-list behavior you actually care about. + +If you are testing inbound processing, switch mental models entirely and use the email event helper path instead of forcing everything through the outbound binding. + +##### Example โ€” Testing an outbound Send Email binding + +```ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('sends an outbound transactional email', async () => { + await expect(env.TRANSACTIONAL_EMAIL.send({ + from: 'noreply@example.com', + to: 'ops@example.com', + subject: 'Smoke check', + text: 'Hello from Devflare' + })).resolves.toBeUndefined() +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use the outbound binding directly when the worker is sending mail. +- Use the inbound `email` helper surface (`cf.email.send(...)` from `devflare/test`) when the worker is handling inbound email in `src/email.ts`. +- Keep address restrictions visible in tests when those restrictions are part of the safety story. + +#### When to move beyond the default harness + +##### Key points + +- Do not document inbound email helper tests as if they were proof of the outbound binding path, or vice versa. +- If external delivery or provider-side verification matters, add a separate integration lane rather than overfitting the local harness. +- The local harness is great for binding behavior, but email product workflows often still need a higher-level end-to-end check. + +> **Important โ€” Two email stories, one docs rule** +> +> Keep outbound binding docs and inbound handler docs adjacent in your head, but separate on the page. That is how people avoid testing the wrong thing. + +--- + +### Use Send Email in a real application path + +> This example keeps outbound email explicit: one binding, one recipient rule, one worker path that sends one message. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/send-email/example`](/docs/bindings/send-email/example) | +| Group | Bindings | +| Navigation title | Send Email example | +| Eyebrow | Application example | + +It is enough to teach the binding accurately without dragging inbound processing or full provider workflows into the very first page. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | Explicit destination rules | +| Runtime shape | Call `send()` from a worker route | +| Best use | Transactional or support notifications | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Minimal Send Email config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'send-email-example', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + sendEmail: { + SUPPORT_EMAIL: { + destinationAddress: 'support@example.com' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Send Email path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first outbound example narrow so the binding contract stays obvious. +- If you also handle inbound email elsewhere in the app, document that on the email-event pages rather than merging the two stories here. + +##### Example โ€” Send one email from the worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.SUPPORT_EMAIL.send({ + from: 'noreply@example.com', + to: 'support@example.com', + subject: 'New support request', + text: 'A customer asked for help.' + }) + + return new Response('sent') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: Explicit destination rules. +- Runtime shape: Call `send()` from a worker route. +- Best use: Transactional or support notifications. + +> **Note โ€” One message is enough to teach the binding** +> +> You do not need a full notification system on the first page. One send call already proves the important contract. + +--- + +### Use Rate Limiting in a Worker + +> Add the Rate Limiting config, call `RateLimit` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/rate-limiting`](/docs/bindings/rate-limiting) | +| Group | Bindings | +| Navigation title | Rate Limiting | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.rateLimits` | +| Authoring shape | `Record` | +| Best for | login throttles, per-user limits, and API guardrails that can use Cloudflare fixed windows | + +#### Add the binding to config + +Add `bindings.rateLimits` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Rate Limiting config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'limited-worker', + bindings: { + rateLimits: { + LOGIN_RATE_LIMIT: { + namespaceId: '1001', + simple: { + limit: 20, + period: 60 + } + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Rate Limiting path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Use the limiter in a request path + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const key = request.headers.get('cf-connecting-ip') ?? 'local' + const outcome = await env.LOGIN_RATE_LIMIT.limit({ key }) + + if (!outcome.success) { + return new Response('slow down', { status: 429 }) + } + + return new Response('ok') +} +``` + +#### Local and Remote Support + +Devflare can run useful Rate Limiting application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Offline-native: Miniflare and Devflare pure mocks can exercise application-level rate limit behavior. Start locally with `createTestContext()` or `createOfflineEnv()`; that lane should cover the normal Rate Limiting application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Rate Limiting details. + +#### When this binding fits best + +##### Key points + +- Use Rate Limiting when login throttles, per-user limits, and api guardrails that can use cloudflare fixed windows. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockRateLimit()` / `createMockEnv({ rateLimits })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Rate Limiting internals** โ€” Check emitted Wrangler `ratelimits`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/rate-limiting/internals)) +- **Testing Rate Limiting** โ€” Pick the `createTestContext()` or `createOfflineEnv()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/rate-limiting/testing)) +- **Rate Limiting example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/rate-limiting/example)) + +--- + +### How Devflare wires Rate Limiting from config to runtime + +> Rate Limiting compiles from `bindings.rateLimits` to Wrangler `ratelimits`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/rate-limiting/internals`](/docs/bindings/rate-limiting/internals) | +| Group | Bindings | +| Navigation title | Rate Limiting internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.rateLimits` before emitting Wrangler `ratelimits` | +| Compile target | Wrangler `ratelimits` | +| Preview note | Cloudflare owns account namespace ids and production enforcement, but the local limiter is useful for deterministic app tests. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Rate Limiting config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'limited-worker', + bindings: { + rateLimits: { + LOGIN_RATE_LIMIT: { + namespaceId: '1001', + simple: { + limit: 20, + period: 60 + } + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "ratelimits": [ + { "name": "LOGIN_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-native: Miniflare and Devflare pure mocks can exercise application-level rate limit behavior +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()`. +- Pure unit tests can use `createMockRateLimit()` / `createMockEnv({ rateLimits })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `ratelimits` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns account namespace ids and production enforcement, but the local limiter is useful for deterministic app tests. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Rate Limiting docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.rateLimits`. + +##### Highlights + +- **Cloudflare Rate Limiting docs** โ€” Platform reference for rate limiting binding configuration, `limit()` calls, locality, and limits. ([link](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for rate limiting binding configuration, `limit()` calls, locality, and limits. | How to author `bindings.rateLimits`, what the runtime surface looks like, and how Rate Limiting fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-native: Miniflare and Devflare pure mocks can exercise application-level rate limit behavior. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Rate Limiting the way Devflare expects it to run + +> Test Rate Limiting by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/rate-limiting/testing`](/docs/bindings/rate-limiting/testing) | +| Group | Bindings | +| Navigation title | Testing Rate Limiting | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | login throttles, per-user limits, and API guardrails that can use Cloudflare fixed windows | +| Default harness | `createTestContext()` or `createOfflineEnv()` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure unit test for rate-limit branching + +```ts +import { expect, test } from 'bun:test' +import { createMockEnv } from 'devflare/test' + +test('blocks the second call in the same window', async () => { + const env = createMockEnv({ + rateLimits: { + LOGIN_RATE_LIMIT: { limit: 1, period: 60 } + } + }) + + expect((await env.LOGIN_RATE_LIMIT.limit({ key: 'user-1' })).success).toBe(true) + expect((await env.LOGIN_RATE_LIMIT.limit({ key: 'user-1' })).success).toBe(false) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockRateLimit()` / `createMockEnv({ rateLimits })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns account namespace ids and production enforcement, but the local limiter is useful for deterministic app tests. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Rate Limiting, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Rate Limiting in a real application path + +> A compact Rate Limiting recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/rate-limiting/example`](/docs/bindings/rate-limiting/example) | +| Group | Bindings | +| Navigation title | Rate Limiting example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.rateLimits | +| Runtime shape | `RateLimit` | +| Best use | login throttles, per-user limits, and API guardrails that can use Cloudflare fixed windows | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Rate Limiting config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'limited-worker', + bindings: { + rateLimits: { + LOGIN_RATE_LIMIT: { + namespaceId: '1001', + simple: { + limit: 20, + period: 60 + } + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Rate Limiting path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. + +##### Example โ€” Use the limiter in a request path + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const key = request.headers.get('cf-connecting-ip') ?? 'local' + const outcome = await env.LOGIN_RATE_LIMIT.limit({ key }) + + if (!outcome.success) { + return new Response('slow down', { status: 429 }) + } + + return new Response('ok') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.rateLimits. +- Runtime shape: `RateLimit`. +- Best use: login throttles, per-user limits, and API guardrails that can use Cloudflare fixed windows. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Version Metadata in a Worker + +> Add the Version Metadata config, call `WorkerVersionMetadata` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/version-metadata`](/docs/bindings/version-metadata) | +| Group | Bindings | +| Navigation title | Version Metadata | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.versionMetadata` | +| Authoring shape | `{ binding: string }` | +| Best for | responses, logs, and diagnostics that need the current Worker version id, tag, or timestamp | + +#### Add the binding to config + +Add `bindings.versionMetadata` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Version Metadata config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'versioned-worker', + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Version Metadata path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Return the current version tag + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return Response.json({ + tag: env.CF_VERSION_METADATA.tag, + id: env.CF_VERSION_METADATA.id + }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Version Metadata application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Offline-native: Devflare can provide deterministic local metadata without Cloudflare state. Start locally with `createTestContext()` or `createOfflineEnv()`; that lane should cover the normal Version Metadata application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Version Metadata details. + +#### When this binding fits best + +##### Key points + +- Use Version Metadata when responses, logs, and diagnostics that need the current worker version id, tag, or timestamp. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockVersionMetadata()` / `createMockEnv({ versionMetadata })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Version Metadata internals** โ€” Check emitted Wrangler `version_metadata`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/version-metadata/internals)) +- **Testing Version Metadata** โ€” Pick the `createTestContext()` or `createOfflineEnv()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/version-metadata/testing)) +- **Version Metadata example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/version-metadata/example)) + +--- + +### How Devflare wires Version Metadata from config to runtime + +> Version Metadata compiles from `bindings.versionMetadata` to Wrangler `version_metadata`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/version-metadata/internals`](/docs/bindings/version-metadata/internals) | +| Group | Bindings | +| Navigation title | Version Metadata internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.versionMetadata` before emitting Wrangler `version_metadata` | +| Compile target | Wrangler `version_metadata` | +| Preview note | Cloudflare supplies real deployment metadata; local tests should assert deterministic fallback behavior only. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Version Metadata config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'versioned-worker', + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "version_metadata": { + "binding": "CF_VERSION_METADATA" + } +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-native: Devflare can provide deterministic local metadata without Cloudflare state +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()`. +- Pure unit tests can use `createMockVersionMetadata()` / `createMockEnv({ versionMetadata })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `version_metadata` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare supplies real deployment metadata; local tests should assert deterministic fallback behavior only. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Version Metadata docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.versionMetadata`. + +##### Highlights + +- **Cloudflare Version Metadata docs** โ€” Platform reference for Worker version id, version tag, and version timestamp bindings. ([link](https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for Worker version id, version tag, and version timestamp bindings. | How to author `bindings.versionMetadata`, what the runtime surface looks like, and how Version Metadata fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-native: Devflare can provide deterministic local metadata without Cloudflare state. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Version Metadata the way Devflare expects it to run + +> Test Version Metadata by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/version-metadata/testing`](/docs/bindings/version-metadata/testing) | +| Group | Bindings | +| Navigation title | Testing Version Metadata | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | responses, logs, and diagnostics that need the current Worker version id, tag, or timestamp | +| Default harness | `createTestContext()` or `createOfflineEnv()` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Assert deterministic local metadata + +```ts +import { expect, test } from 'bun:test' +import { createMockEnv } from 'devflare/test' + +test('uses deterministic local version metadata', () => { + const env = createMockEnv({ versionMetadata: 'CF_VERSION_METADATA' }) + + expect(env.CF_VERSION_METADATA.tag).toBe('local') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockVersionMetadata()` / `createMockEnv({ versionMetadata })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare supplies real deployment metadata; local tests should assert deterministic fallback behavior only. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Version Metadata, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Version Metadata in a real application path + +> A compact Version Metadata recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/version-metadata/example`](/docs/bindings/version-metadata/example) | +| Group | Bindings | +| Navigation title | Version Metadata example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.versionMetadata | +| Runtime shape | `WorkerVersionMetadata` | +| Best use | responses, logs, and diagnostics that need the current Worker version id, tag, or timestamp | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Version Metadata config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'versioned-worker', + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Version Metadata path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. + +##### Example โ€” Return the current version tag + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return Response.json({ + tag: env.CF_VERSION_METADATA.tag, + id: env.CF_VERSION_METADATA.id + }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.versionMetadata. +- Runtime shape: `WorkerVersionMetadata`. +- Best use: responses, logs, and diagnostics that need the current Worker version id, tag, or timestamp. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Worker Loaders in a Worker + +> Add the Worker Loaders config, call `WorkerLoader` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/worker-loaders`](/docs/bindings/worker-loaders) | +| Group | Bindings | +| Navigation title | Worker Loaders | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.workerLoaders` | +| Authoring shape | `Record` | +| Best for | Dynamic Workers where the app loads Worker code at runtime from an explicit source | + +#### Add the binding to config + +Add `bindings.workerLoaders` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Worker Loader config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'loader-worker', + bindings: { + workerLoaders: { + LOADER: {} + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Worker Loaders path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Load an explicit Worker payload + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const stub = env.LOADER.get('tenant-a', () => ({ + compatibilityDate: '2026-04-26', + mainModule: 'index.js', + modules: { + 'index.js': 'export default { fetch() { return new Response("ok") } }' + } + })) + + return stub.fetch(request) +} +``` + +#### Local and Remote Support + +Devflare can run useful Worker Loaders application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support through Miniflare Worker Loader bindings and explicit pure-test Worker stubs. Start locally with `createTestContext()` with explicit Worker payloads or a pure stub; that lane should cover the normal Worker Loaders application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Worker Loaders details. + +#### When this binding fits best + +##### Key points + +- Use Worker Loaders when dynamic workers where the app loads worker code at runtime from an explicit source. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` with explicit Worker payloads or a pure stub for config-backed local worker tests. +- Use `createMockWorkerLoader()` / `createMockEnv({ workerLoaders })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Worker Loaders internals** โ€” Check emitted Wrangler `worker_loaders`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/worker-loaders/internals)) +- **Testing Worker Loaders** โ€” Pick the `createTestContext()` with explicit Worker payloads or a pure stub path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/worker-loaders/testing)) +- **Worker Loaders example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/worker-loaders/example)) + +--- + +### How Devflare wires Worker Loaders from config to runtime + +> Worker Loaders compiles from `bindings.workerLoaders` to Wrangler `worker_loaders`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/worker-loaders/internals`](/docs/bindings/worker-loaders/internals) | +| Group | Bindings | +| Navigation title | Worker Loaders internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.workerLoaders` before emitting Wrangler `worker_loaders` | +| Compile target | Wrangler `worker_loaders` | +| Preview note | Cloudflare owns dynamic Worker upload, discovery, and hosted lifecycle; local code should pass explicit payloads or stubs. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Worker Loaders config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'loader-worker', + bindings: { + workerLoaders: { + LOADER: {} + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "worker_loaders": [ + { "binding": "LOADER" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Full local support through Miniflare Worker Loader bindings and explicit pure-test Worker stubs +- The default docs recipe uses `createTestContext()` with explicit Worker payloads or a pure stub. +- Pure unit tests can use `createMockWorkerLoader()` / `createMockEnv({ workerLoaders })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `worker_loaders` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns dynamic Worker upload, discovery, and hosted lifecycle; local code should pass explicit payloads or stubs. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Dynamic Worker Loaders docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.workerLoaders`. + +##### Highlights + +- **Cloudflare Dynamic Worker Loaders docs** โ€” Platform reference for loading dynamic Workers and arbitrary Worker code at runtime. ([link](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for loading dynamic Workers and arbitrary Worker code at runtime. | How to author `bindings.workerLoaders`, what the runtime surface looks like, and how Worker Loaders fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support through Miniflare Worker Loader bindings and explicit pure-test Worker stubs. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Worker Loaders the way Devflare expects it to run + +> Test Worker Loaders by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/worker-loaders/testing`](/docs/bindings/worker-loaders/testing) | +| Group | Bindings | +| Navigation title | Testing Worker Loaders | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Dynamic Workers where the app loads Worker code at runtime from an explicit source | +| Default harness | `createTestContext()` with explicit Worker payloads or a pure stub | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure test with an explicit Worker stub + +```ts +import { expect, test } from 'bun:test' +import { createMockWorkerLoader } from 'devflare/test' + +test('uses a supplied dynamic Worker stub', async () => { + const loader = createMockWorkerLoader({ + stub: { + fetch: async () => new Response('tenant-ok') + } + }) + + const stub = loader.get('tenant-a', () => ({ mainModule: 'index.js', modules: {} })) + expect(await (await stub.fetch('https://example.com')).text()).toBe('tenant-ok') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` with explicit Worker payloads or a pure stub for config-backed local worker tests. +- Use `createMockWorkerLoader()` / `createMockEnv({ workerLoaders })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns dynamic Worker upload, discovery, and hosted lifecycle; local code should pass explicit payloads or stubs. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Worker Loaders, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Worker Loaders in a real application path + +> A compact Worker Loaders recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/worker-loaders/example`](/docs/bindings/worker-loaders/example) | +| Group | Bindings | +| Navigation title | Worker Loaders example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.workerLoaders | +| Runtime shape | `WorkerLoader` | +| Best use | Dynamic Workers where the app loads Worker code at runtime from an explicit source | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Worker Loader config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'loader-worker', + bindings: { + workerLoaders: { + LOADER: {} + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Worker Loaders path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns dynamic Worker upload, discovery, and hosted lifecycle; local code should pass explicit payloads or stubs. + +##### Example โ€” Load an explicit Worker payload + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const stub = env.LOADER.get('tenant-a', () => ({ + compatibilityDate: '2026-04-26', + mainModule: 'index.js', + modules: { + 'index.js': 'export default { fetch() { return new Response("ok") } }' + } + })) + + return stub.fetch(request) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.workerLoaders. +- Runtime shape: `WorkerLoader`. +- Best use: Dynamic Workers where the app loads Worker code at runtime from an explicit source. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Secrets Store in a Worker + +> Add the Secrets Store config, call `SecretsStoreSecret` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/secrets-store`](/docs/bindings/secrets-store) | +| Group | Bindings | +| Navigation title | Secrets Store | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.secretsStore` | +| Authoring shape | `secretsStoreId + Record` | +| Best for | shared account secrets that should be referenced by store id and secret name instead of copied into config | + +#### Add the binding to config + +Add `bindings.secretsStore` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Secrets Store config with one default store + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'secret-worker', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + STRIPE_WEBHOOK_SECRET: 'stripe-webhook-secret' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Secrets Store path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Protect an internal route with a shared API token + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const token = await env.API_TOKEN.get() + const authorization = request.headers.get('authorization') + + if (authorization !== 'Bearer ' + token) { + return new Response('unauthorized', { status: 401 }) + } + + return Response.json({ ok: true }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Secrets Store application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values for pure tests. Start locally with `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })`; that lane should cover the normal Secrets Store application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Secrets Store details. + +#### Set local values without putting secrets in config + +Keep `devflare.config.ts` limited to store IDs and secret names. Use the CLI to write local values into `.devflare/secrets.local.json`, then let dev, `createTestContext()`, and `createOfflineEnv(..., { cwd })` read those values locally. + +##### Key points + +- The Worker sees a read-only `SecretsStoreSecret` binding. +- CLI output lists `store/name` references; it does not print secret values. +- Use explicit `{ storeId, secretName }` binding objects only when one Worker needs secrets from multiple stores. + +##### Example โ€” Create a local secret value + +```bash +devflare secrets --local --store store-123 --name api-token --value local-token +devflare secrets --local --store store-123 --list +``` + +#### When this binding fits best + +##### Key points + +- Use Secrets Store when shared account secrets that should be referenced by store id and secret name instead of copied into config. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })` for config-backed local worker tests. +- Use `createMockSecretsStoreSecret()` / `createMockEnv({ secretsStore })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Secrets Store internals** โ€” Check emitted Wrangler `secrets_store_secrets`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/secrets-store/internals)) +- **Testing Secrets Store** โ€” Pick the `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/secrets-store/testing)) +- **Secrets Store example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/secrets-store/example)) + +--- + +### How Devflare wires Secrets Store from config to runtime + +> Secrets Store compiles from `bindings.secretsStore` to Wrangler `secrets_store_secrets`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/secrets-store/internals`](/docs/bindings/secrets-store/internals) | +| Group | Bindings | +| Navigation title | Secrets Store internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.secretsStore` before emitting Wrangler `secrets_store_secrets` | +| Compile target | Wrangler `secrets_store_secrets` | +| Preview note | Cloudflare owns remote account secret provisioning and sync; Devflare reads only project-local secret values unless you deploy or test against Cloudflare. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Secrets Store config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'secret-worker', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + STRIPE_WEBHOOK_SECRET: 'stripe-webhook-secret' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "secrets_store_secrets": [ + { "binding": "API_TOKEN", "store_id": "store-123", "secret_name": "api-token" }, + { "binding": "STRIPE_WEBHOOK_SECRET", "store_id": "store-123", "secret_name": "stripe-webhook-secret" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values for pure tests +- The default docs recipe uses `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })`. +- Pure unit tests can use `createMockSecretsStoreSecret()` / `createMockEnv({ secretsStore })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `secrets_store_secrets` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns remote account secret provisioning and sync; Devflare reads only project-local secret values unless you deploy or test against Cloudflare. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Secrets Store docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.secretsStore`. + +##### Highlights + +- **Cloudflare Secrets Store docs** โ€” Platform reference for secrets, account-level Secrets Store bindings, and secure Worker access. ([link](https://developers.cloudflare.com/workers/configuration/secrets/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for secrets, account-level Secrets Store bindings, and secure Worker access. | How to author `bindings.secretsStore`, what the runtime surface looks like, and how Secrets Store fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values for pure tests. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Secrets Store the way Devflare expects it to run + +> Test Secrets Store by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/secrets-store/testing`](/docs/bindings/secrets-store/testing) | +| Group | Bindings | +| Navigation title | Testing Secrets Store | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | shared account secrets that should be referenced by store id and secret name instead of copied into config | +| Default harness | `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Fixture a Secrets Store value offline + +```ts +import { expect, test } from 'bun:test' +import { createOfflineEnv } from 'devflare/test' +import config from '../devflare.config' + +test('reads a fixed offline secret', async () => { + const env = createOfflineEnv(config, { + secretsStore: { + API_TOKEN: 'test-token' + } + }) + +expect(await env.API_TOKEN.get()).toBe('test-token') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv(config, fixtures, { cwd })` for config-backed local worker tests. +- Use `createMockSecretsStoreSecret()` / `createMockEnv({ secretsStore })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns remote account secret provisioning and sync; Devflare reads only project-local secret values unless you deploy or test against Cloudflare. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Secrets Store, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Secrets Store in a real application path + +> A compact Secrets Store recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/secrets-store/example`](/docs/bindings/secrets-store/example) | +| Group | Bindings | +| Navigation title | Secrets Store example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.secretsStore | +| Runtime shape | `SecretsStoreSecret` | +| Best use | shared account secrets that should be referenced by store id and secret name instead of copied into config | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Secrets Store config with one default store + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'secret-worker', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + STRIPE_WEBHOOK_SECRET: 'stripe-webhook-secret' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Secrets Store path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. + +##### Example โ€” Protect an internal route with a shared API token + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const token = await env.API_TOKEN.get() + const authorization = request.headers.get('authorization') + + if (authorization !== 'Bearer ' + token) { + return new Response('unauthorized', { status: 401 }) + } + + return Response.json({ ok: true }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.secretsStore. +- Runtime shape: `SecretsStoreSecret`. +- Best use: shared account secrets that should be referenced by store id and secret name instead of copied into config. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use AI Search in a Worker + +> Add the AI Search config, call `AiSearchInstance` or `AiSearchNamespace` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai-search`](/docs/bindings/ai-search) | +| Group | Bindings | +| Navigation title | AI Search | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.aiSearch` | +| Authoring shape | `Record plus aiSearchNamespaces for namespace access` | +| Best for | search/chat flows where the app calls an AI Search instance or namespace from a Worker | + +#### Add the binding to config + +Add `bindings.aiSearch` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest AI Search config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs-search' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first AI Search path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Search one AI Search instance + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const query = new URL(request.url).searchParams.get('q') ?? 'devflare' + const result = await env.DOCS_SEARCH.search({ query }) + + return Response.json(result.chunks) +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Offline-fixture: deterministic in-memory instances can test application flow, not hosted relevance behavior. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the assertion depends on cloudflare-hosted product behavior rather than the app calling the binding correctly. This is the lane for full AI Search product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use AI Search when search/chat flows where the app calls an ai search instance or namespace from a worker. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createOfflineEnv()` with AI Search fixtures for config-backed local worker tests. +- Use `createMockAISearchInstance()` / `createMockAISearchNamespace()` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **AI Search internals** โ€” Check emitted Wrangler `ai_search` / `ai_search_namespaces`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/ai-search/internals)) +- **Testing AI Search** โ€” Pick the `createOfflineEnv()` with AI Search fixtures path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/ai-search/testing)) +- **AI Search example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/ai-search/example)) + +--- + +### How Devflare wires AI Search from config to runtime + +> AI Search compiles from `bindings.aiSearch` to Wrangler `ai_search` / `ai_search_namespaces`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai-search/internals`](/docs/bindings/ai-search/internals) | +| Group | Bindings | +| Navigation title | AI Search internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.aiSearch` before emitting Wrangler `ai_search` / `ai_search_namespaces` | +| Compile target | Wrangler `ai_search` / `ai_search_namespaces` | +| Preview note | Cloudflare owns crawling, indexing, ranking, and hosted model behavior; local mocks only prove app control flow. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” AI Search config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs-search' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "ai_search": [ + { "binding": "DOCS_SEARCH", "instance_name": "docs-search" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-fixture: deterministic in-memory instances can test application flow, not hosted relevance behavior +- The default docs recipe uses `createOfflineEnv()` with AI Search fixtures. +- Pure unit tests can use `createMockAISearchInstance()` / `createMockAISearchNamespace()` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `ai_search` / `ai_search_namespaces` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns crawling, indexing, ranking, and hosted model behavior; local mocks only prove app control flow. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare AI Search docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.aiSearch`. + +##### Highlights + +- **Cloudflare AI Search docs** โ€” Platform reference for AI Search instance and namespace bindings from Workers. ([link](https://developers.cloudflare.com/ai-search/api/search/workers-binding/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for AI Search instance and namespace bindings from Workers. | How to author `bindings.aiSearch`, what the runtime surface looks like, and how AI Search fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-fixture: deterministic in-memory instances can test application flow, not hosted relevance behavior. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test AI Search the way Devflare expects it to run + +> Test AI Search by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai-search/testing`](/docs/bindings/ai-search/testing) | +| Group | Bindings | +| Navigation title | Testing AI Search | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | search/chat flows where the app calls an AI Search instance or namespace from a Worker | +| Default harness | `createOfflineEnv()` with AI Search fixtures | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Fixture AI Search results offline + +```ts +import { expect, test } from 'bun:test' +import { createMockAISearchInstance } from 'devflare/test' + +test('finds fixture content', async () => { + const search = createMockAISearchInstance({ + items: [{ key: 'offline.md', content: 'Offline fixtures make tests deterministic' }] + }) + + const result = await search.search({ query: 'fixtures' }) + expect(result.chunks.length).toBeGreaterThan(0) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createOfflineEnv()` with AI Search fixtures for config-backed local worker tests. +- Use `createMockAISearchInstance()` / `createMockAISearchNamespace()` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns crawling, indexing, ranking, and hosted model behavior; local mocks only prove app control flow. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For AI Search, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use AI Search in a real application path + +> A compact AI Search recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/ai-search/example`](/docs/bindings/ai-search/example) | +| Group | Bindings | +| Navigation title | AI Search example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.aiSearch | +| Runtime shape | `AiSearchInstance` or `AiSearchNamespace` | +| Best use | search/chat flows where the app calls an AI Search instance or namespace from a Worker | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest AI Search config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'search-worker', + bindings: { + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs-search' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level AI Search path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns crawling, indexing, ranking, and hosted model behavior; local mocks only prove app control flow. + +##### Example โ€” Search one AI Search instance + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const query = new URL(request.url).searchParams.get('q') ?? 'devflare' + const result = await env.DOCS_SEARCH.search({ query }) + + return Response.json(result.chunks) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.aiSearch. +- Runtime shape: `AiSearchInstance` or `AiSearchNamespace`. +- Best use: search/chat flows where the app calls an AI Search instance or namespace from a Worker. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use mTLS Certificates in a Worker + +> Add the mTLS Certificates config, call `Fetcher` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/mtls-certificates`](/docs/bindings/mtls-certificates) | +| Group | Bindings | +| Navigation title | mTLS Certificates | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.mtlsCertificates` | +| Authoring shape | `Record` | +| Best for | calling origins that require a Cloudflare-uploaded client certificate | + +#### Add the binding to config + +Add `bindings.mtlsCertificates` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest mTLS Certificate config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'mtls-worker', + bindings: { + mtlsCertificates: { + CLIENT_CERT: { + certificateId: 'certificate-uuid' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first mTLS Certificates path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Fetch through the mTLS binding + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return env.CLIENT_CERT.fetch('https://origin.example/status') +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Offline-fixture: local tests can model Fetcher behavior, but not real certificate presentation. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the assertion depends on cloudflare-hosted product behavior rather than the app calling the binding correctly. This is the lane for full mTLS Certificates product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use mTLS Certificates when calling origins that require a cloudflare-uploaded client certificate. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createOfflineEnv()` with `fixtures.mtlsCertificates` for config-backed local worker tests. +- Use `createMockMTLSCertificate()` / `createMockEnv({ mtlsCertificates })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **mTLS Certificates internals** โ€” Check emitted Wrangler `mtls_certificates`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/mtls-certificates/internals)) +- **Testing mTLS Certificates** โ€” Pick the `createOfflineEnv()` with `fixtures.mtlsCertificates` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/mtls-certificates/testing)) +- **mTLS Certificates example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/mtls-certificates/example)) + +--- + +### How Devflare wires mTLS Certificates from config to runtime + +> mTLS Certificates compiles from `bindings.mtlsCertificates` to Wrangler `mtls_certificates`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/mtls-certificates/internals`](/docs/bindings/mtls-certificates/internals) | +| Group | Bindings | +| Navigation title | mTLS Certificates internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.mtlsCertificates` before emitting Wrangler `mtls_certificates` | +| Compile target | Wrangler `mtls_certificates` | +| Preview note | Real TLS client-certificate presentation is Cloudflare/Wrangler remote behavior. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” mTLS Certificates config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'mtls-worker', + bindings: { + mtlsCertificates: { + CLIENT_CERT: { + certificateId: 'certificate-uuid' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "mtls_certificates": [ + { "binding": "CLIENT_CERT", "certificate_id": "certificate-uuid" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-fixture: local tests can model Fetcher behavior, but not real certificate presentation +- The default docs recipe uses `createOfflineEnv()` with `fixtures.mtlsCertificates`. +- Pure unit tests can use `createMockMTLSCertificate()` / `createMockEnv({ mtlsCertificates })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `mtls_certificates` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Real TLS client-certificate presentation is Cloudflare/Wrangler remote behavior. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare mTLS docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.mtlsCertificates`. + +##### Highlights + +- **Cloudflare mTLS docs** โ€” Platform reference for mTLS certificate bindings and certificate-backed outbound fetches. ([link](https://developers.cloudflare.com/workers/runtime-apis/bindings/mtls/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for mTLS certificate bindings and certificate-backed outbound fetches. | How to author `bindings.mtlsCertificates`, what the runtime surface looks like, and how mTLS Certificates fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-fixture: local tests can model Fetcher behavior, but not real certificate presentation. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test mTLS Certificates the way Devflare expects it to run + +> Test mTLS Certificates by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/mtls-certificates/testing`](/docs/bindings/mtls-certificates/testing) | +| Group | Bindings | +| Navigation title | Testing mTLS Certificates | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | calling origins that require a Cloudflare-uploaded client certificate | +| Default harness | `createOfflineEnv()` with `fixtures.mtlsCertificates` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Fixture an mTLS Fetcher locally + +```ts +import { expect, test } from 'bun:test' +import { createMockMTLSCertificate } from 'devflare/test' + +test('uses a local mTLS Fetcher fixture', async () => { + const cert = createMockMTLSCertificate(async () => Response.json({ ok: true })) + const response = await cert.fetch('https://origin.example/status') + + expect(await response.json()).toEqual({ ok: true }) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createOfflineEnv()` with `fixtures.mtlsCertificates` for config-backed local worker tests. +- Use `createMockMTLSCertificate()` / `createMockEnv({ mtlsCertificates })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Real TLS client-certificate presentation is Cloudflare/Wrangler remote behavior. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For mTLS Certificates, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use mTLS Certificates in a real application path + +> A compact mTLS Certificates recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/mtls-certificates/example`](/docs/bindings/mtls-certificates/example) | +| Group | Bindings | +| Navigation title | mTLS Certificates example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.mtlsCertificates | +| Runtime shape | `Fetcher` | +| Best use | calling origins that require a Cloudflare-uploaded client certificate | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest mTLS Certificate config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'mtls-worker', + bindings: { + mtlsCertificates: { + CLIENT_CERT: { + certificateId: 'certificate-uuid' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level mTLS Certificates path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Real TLS client-certificate presentation is Cloudflare/Wrangler remote behavior. + +##### Example โ€” Fetch through the mTLS binding + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + return env.CLIENT_CERT.fetch('https://origin.example/status') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.mtlsCertificates. +- Runtime shape: `Fetcher`. +- Best use: calling origins that require a Cloudflare-uploaded client certificate. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Dispatch Namespaces in a Worker + +> Add the Dispatch Namespaces config, call `DispatchNamespace` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/dispatch-namespaces`](/docs/bindings/dispatch-namespaces) | +| Group | Bindings | +| Navigation title | Dispatch Namespaces | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.dispatchNamespaces` | +| Authoring shape | `Record` | +| Best for | platform Workers that dispatch to tenant Workers by name | + +#### Add the binding to config + +Add `bindings.dispatchNamespaces` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Dispatch Namespace config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'platform-worker', + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'tenants' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Dispatch Namespaces path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Dispatch to one tenant Worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const tenant = new URL(request.url).searchParams.get('tenant') ?? 'default' + return env.DISPATCHER.get(tenant).fetch(request) +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Offline-fixture: tests can provide named tenant fetchers, but Devflare does not emulate tenant upload/lifecycle. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the assertion depends on cloudflare-hosted product behavior rather than the app calling the binding correctly. This is the lane for full Dispatch Namespaces product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use Dispatch Namespaces when platform workers that dispatch to tenant workers by name. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createOfflineEnv()` with `fixtures.dispatchNamespaces` for config-backed local worker tests. +- Use `createMockDispatchNamespace()` / `createMockEnv({ dispatchNamespaces })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Dispatch Namespaces internals** โ€” Check emitted Wrangler `dispatch_namespaces`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/dispatch-namespaces/internals)) +- **Testing Dispatch Namespaces** โ€” Pick the `createOfflineEnv()` with `fixtures.dispatchNamespaces` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/dispatch-namespaces/testing)) +- **Dispatch Namespaces example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/dispatch-namespaces/example)) + +--- + +### How Devflare wires Dispatch Namespaces from config to runtime + +> Dispatch Namespaces compiles from `bindings.dispatchNamespaces` to Wrangler `dispatch_namespaces`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/dispatch-namespaces/internals`](/docs/bindings/dispatch-namespaces/internals) | +| Group | Bindings | +| Navigation title | Dispatch Namespaces internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.dispatchNamespaces` before emitting Wrangler `dispatch_namespaces` | +| Compile target | Wrangler `dispatch_namespaces` | +| Preview note | Cloudflare owns dispatch namespace creation, tenant uploads, Worker metadata, and production routing. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Dispatch Namespaces config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'platform-worker', + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'tenants' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "dispatch_namespaces": [ + { "binding": "DISPATCHER", "namespace": "tenants" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-fixture: tests can provide named tenant fetchers, but Devflare does not emulate tenant upload/lifecycle +- The default docs recipe uses `createOfflineEnv()` with `fixtures.dispatchNamespaces`. +- Pure unit tests can use `createMockDispatchNamespace()` / `createMockEnv({ dispatchNamespaces })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `dispatch_namespaces` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns dispatch namespace creation, tenant uploads, Worker metadata, and production routing. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Workers for Platforms docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.dispatchNamespaces`. + +##### Highlights + +- **Cloudflare Workers for Platforms docs** โ€” Platform reference for dispatch namespaces, dynamic dispatch Workers, and tenant Worker routing. ([link](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for dispatch namespaces, dynamic dispatch Workers, and tenant Worker routing. | How to author `bindings.dispatchNamespaces`, what the runtime surface looks like, and how Dispatch Namespaces fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-fixture: tests can provide named tenant fetchers, but Devflare does not emulate tenant upload/lifecycle. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Dispatch Namespaces the way Devflare expects it to run + +> Test Dispatch Namespaces by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/dispatch-namespaces/testing`](/docs/bindings/dispatch-namespaces/testing) | +| Group | Bindings | +| Navigation title | Testing Dispatch Namespaces | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | platform Workers that dispatch to tenant Workers by name | +| Default harness | `createOfflineEnv()` with `fixtures.dispatchNamespaces` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Fixture tenant dispatch locally + +```ts +import { expect, test } from 'bun:test' +import { createMockDispatchNamespace } from 'devflare/test' + +test('dispatches to a configured tenant', async () => { + const dispatcher = createMockDispatchNamespace({ + workers: { + default: async () => new Response('tenant-ok') + } + }) + + expect(await (await dispatcher.get('default').fetch('https://example.com')).text()).toBe('tenant-ok') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createOfflineEnv()` with `fixtures.dispatchNamespaces` for config-backed local worker tests. +- Use `createMockDispatchNamespace()` / `createMockEnv({ dispatchNamespaces })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns dispatch namespace creation, tenant uploads, Worker metadata, and production routing. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Dispatch Namespaces, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Dispatch Namespaces in a real application path + +> A compact Dispatch Namespaces recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/dispatch-namespaces/example`](/docs/bindings/dispatch-namespaces/example) | +| Group | Bindings | +| Navigation title | Dispatch Namespaces example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.dispatchNamespaces | +| Runtime shape | `DispatchNamespace` | +| Best use | platform Workers that dispatch to tenant Workers by name | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Dispatch Namespace config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'platform-worker', + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'tenants' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Dispatch Namespaces path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns dispatch namespace creation, tenant uploads, Worker metadata, and production routing. + +##### Example โ€” Dispatch to one tenant Worker + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + const tenant = new URL(request.url).searchParams.get('tenant') ?? 'default' + return env.DISPATCHER.get(tenant).fetch(request) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.dispatchNamespaces. +- Runtime shape: `DispatchNamespace`. +- Best use: platform Workers that dispatch to tenant Workers by name. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Workflows in a Worker + +> Add the Workflows config, call `Workflow` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/workflows`](/docs/bindings/workflows) | +| Group | Bindings | +| Navigation title | Workflows | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.workflows` | +| Authoring shape | `Record` | +| Best for | starting long-running workflow instances from a Worker path | + +#### Add the binding to config + +Add `bindings.workflows` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Workflow binding config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workflow-client', + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'order-workflow', + className: 'OrderWorkflow' + } + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Workflows path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Define and start one order workflow + +```ts +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers' +import { env } from 'devflare/runtime' + +type OrderWorkflowParams = { + orderId: string + email: string +} + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep): Promise { + const invoice = await step.do('create invoice', async () => { + return { id: 'inv_' + event.payload.orderId, email: event.payload.email } + }) + + await step.do('send confirmation', async () => { + await fetch('https://api.example.com/confirmations', { + method: 'POST', + body: JSON.stringify(invoice) + }) + return { queued: true } + }) + + return invoice + } +} + +export async function fetch(request: Request): Promise { + const orderId = new URL(request.url).searchParams.get('order') ?? 'demo' + const instance = await env.ORDER_WORKFLOW.create({ + id: orderId, + params: { orderId, email: 'customer@example.com' } + }) + + return Response.json({ id: instance.id }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Workflows application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support through Miniflare workflow bindings and deterministic workflow mocks. Start locally with `createTestContext()` or `createOfflineEnv()`; that lane should cover the normal Workflows application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Workflows details. + +#### When this binding fits best + +##### Key points + +- Use Workflows when starting long-running workflow instances from a worker path. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockWorkflow()` / `createMockEnv({ workflows })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Workflows internals** โ€” Check emitted Wrangler `workflows`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/workflows/internals)) +- **Testing Workflows** โ€” Pick the `createTestContext()` or `createOfflineEnv()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/workflows/testing)) +- **Workflows example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/workflows/example)) + +--- + +### How Devflare wires Workflows from config to runtime + +> Workflows compiles from `bindings.workflows` to Wrangler `workflows`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/workflows/internals`](/docs/bindings/workflows/internals) | +| Group | Bindings | +| Navigation title | Workflows internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.workflows` before emitting Wrangler `workflows` | +| Compile target | Wrangler `workflows` | +| Preview note | Cloudflare owns deployed Workflow durability, retries, scheduling, and production instance history. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Workflows config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workflow-client', + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'order-workflow', + className: 'OrderWorkflow' + } + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "workflows": [ + { "binding": "ORDER_WORKFLOW", "name": "order-workflow", "class_name": "OrderWorkflow" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Full local support through Miniflare workflow bindings and deterministic workflow mocks +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()`. +- Pure unit tests can use `createMockWorkflow()` / `createMockEnv({ workflows })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `workflows` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns deployed Workflow durability, retries, scheduling, and production instance history. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Workflows docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.workflows`. + +##### Highlights + +- **Cloudflare Workflows docs** โ€” Platform reference for creating Workflow bindings and triggering Workflow instances from Workers. ([link](https://developers.cloudflare.com/workflows/build/trigger-workflows/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for creating Workflow bindings and triggering Workflow instances from Workers. | How to author `bindings.workflows`, what the runtime surface looks like, and how Workflows fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support through Miniflare workflow bindings and deterministic workflow mocks. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Workflows the way Devflare expects it to run + +> Test Workflows by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/workflows/testing`](/docs/bindings/workflows/testing) | +| Group | Bindings | +| Navigation title | Testing Workflows | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | starting long-running workflow instances from a Worker path | +| Default harness | `createTestContext()` or `createOfflineEnv()` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure workflow call test + +```ts +import { expect, test } from 'bun:test' +import { createMockWorkflow } from 'devflare/test' + +test('creates a workflow instance', async () => { + const workflow = createMockWorkflow() + const instance = await workflow.create({ id: 'order-1', params: { orderId: 'order-1' } }) + + expect(instance.id).toBe('order-1') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockWorkflow()` / `createMockEnv({ workflows })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns deployed Workflow durability, retries, scheduling, and production instance history. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Workflows, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Workflows in a real application path + +> A compact Workflows recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/workflows/example`](/docs/bindings/workflows/example) | +| Group | Bindings | +| Navigation title | Workflows example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.workflows | +| Runtime shape | `Workflow` | +| Best use | starting long-running workflow instances from a Worker path | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Workflow binding config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'workflow-client', + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'order-workflow', + className: 'OrderWorkflow' + } + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Workflows path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns deployed Workflow durability, retries, scheduling, and production instance history. + +##### Example โ€” Define and start one order workflow + +```ts +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers' +import { env } from 'devflare/runtime' + +type OrderWorkflowParams = { + orderId: string + email: string +} + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep): Promise { + const invoice = await step.do('create invoice', async () => { + return { id: 'inv_' + event.payload.orderId, email: event.payload.email } + }) + + await step.do('send confirmation', async () => { + await fetch('https://api.example.com/confirmations', { + method: 'POST', + body: JSON.stringify(invoice) + }) + return { queued: true } + }) + + return invoice + } +} + +export async function fetch(request: Request): Promise { + const orderId = new URL(request.url).searchParams.get('order') ?? 'demo' + const instance = await env.ORDER_WORKFLOW.create({ + id: orderId, + params: { orderId, email: 'customer@example.com' } + }) + + return Response.json({ id: instance.id }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.workflows. +- Runtime shape: `Workflow`. +- Best use: starting long-running workflow instances from a Worker path. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Pipelines in a Worker + +> Add the Pipelines config, call `Pipeline` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/pipelines`](/docs/bindings/pipelines) | +| Group | Bindings | +| Navigation title | Pipelines | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.pipelines` | +| Authoring shape | `Record` | +| Best for | Worker-side event ingestion into Cloudflare Pipelines | + +#### Add the binding to config + +Add `bindings.pipelines` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Pipeline config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'events-worker', + bindings: { + pipelines: { + EVENTS: 'app-events' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Pipelines path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Send one record batch + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.EVENTS.send([ + { timestamp: Date.now(), message: 'signup' } + ]) + + return new Response('recorded') +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Offline-native for send-recording tests; Cloudflare owns production batching and sink delivery. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the assertion depends on cloudflare-hosted product behavior rather than the app calling the binding correctly. This is the lane for full Pipelines product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use Pipelines when worker-side event ingestion into cloudflare pipelines. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockPipeline()` / `createMockEnv({ pipelines })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Pipelines internals** โ€” Check emitted Wrangler `pipelines`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/pipelines/internals)) +- **Testing Pipelines** โ€” Pick the `createTestContext()` or `createOfflineEnv()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/pipelines/testing)) +- **Pipelines example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/pipelines/example)) + +--- + +### How Devflare wires Pipelines from config to runtime + +> Pipelines compiles from `bindings.pipelines` to Wrangler `pipelines`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/pipelines/internals`](/docs/bindings/pipelines/internals) | +| Group | Bindings | +| Navigation title | Pipelines internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.pipelines` before emitting Wrangler `pipelines` | +| Compile target | Wrangler `pipelines` | +| Preview note | Devflare records local sends but does not create pipelines, manage R2 sinks, or emulate production batching. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Pipelines config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'events-worker', + bindings: { + pipelines: { + EVENTS: 'app-events' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "pipelines": [ + { "binding": "EVENTS", "pipeline": "app-events" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-native for send-recording tests; Cloudflare owns production batching and sink delivery +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()`. +- Pure unit tests can use `createMockPipeline()` / `createMockEnv({ pipelines })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `pipelines` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Devflare records local sends but does not create pipelines, manage R2 sinks, or emulate production batching. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Pipelines docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.pipelines`. + +##### Highlights + +- **Cloudflare Pipelines docs** โ€” Platform reference for sending records from Workers into Cloudflare Pipelines. ([link](https://developers.cloudflare.com/pipelines/build-with-pipelines/sources/workers-apis/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for sending records from Workers into Cloudflare Pipelines. | How to author `bindings.pipelines`, what the runtime surface looks like, and how Pipelines fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-native for send-recording tests; Cloudflare owns production batching and sink delivery. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Pipelines the way Devflare expects it to run + +> Test Pipelines by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/pipelines/testing`](/docs/bindings/pipelines/testing) | +| Group | Bindings | +| Navigation title | Testing Pipelines | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Worker-side event ingestion into Cloudflare Pipelines | +| Default harness | `createTestContext()` or `createOfflineEnv()` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Assert recorded Pipeline sends + +```ts +import { expect, test } from 'bun:test' +import { createMockPipeline } from 'devflare/test' + +test('records sent pipeline rows', async () => { + const pipeline = createMockPipeline() + await pipeline.send([{ message: 'signup' }]) + + expect(pipeline._getRecords()).toEqual([{ message: 'signup' }]) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockPipeline()` / `createMockEnv({ pipelines })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Devflare records local sends but does not create pipelines, manage R2 sinks, or emulate production batching. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Pipelines, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Pipelines in a real application path + +> A compact Pipelines recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/pipelines/example`](/docs/bindings/pipelines/example) | +| Group | Bindings | +| Navigation title | Pipelines example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.pipelines | +| Runtime shape | `Pipeline` | +| Best use | Worker-side event ingestion into Cloudflare Pipelines | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Pipeline config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'events-worker', + bindings: { + pipelines: { + EVENTS: 'app-events' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Pipelines path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Devflare records local sends but does not create pipelines, manage R2 sinks, or emulate production batching. + +##### Example โ€” Send one record batch + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + await env.EVENTS.send([ + { timestamp: Date.now(), message: 'signup' } + ]) + + return new Response('recorded') +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.pipelines. +- Runtime shape: `Pipeline`. +- Best use: Worker-side event ingestion into Cloudflare Pipelines. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Images in a Worker + +> Add the Images config, call `ImagesBinding` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/images`](/docs/bindings/images) | +| Group | Bindings | +| Navigation title | Images | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.images` | +| Authoring shape | `Record` | +| Best for | image transformation/upload paths where the Worker calls the Images binding | + +#### Add the binding to config + +Add `bindings.images` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Images config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'images-worker', + bindings: { + images: { + IMAGES: true + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Images path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Transform uploaded image bytes + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing image', { status: 400 }) + } + + return env.IMAGES + .input(request.body) + .transform({ width: 320 }) + .output({ format: 'image/jpeg' }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Images application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support through Miniflare image bindings, persisted local state, and deterministic pure mocks. Start locally with `createTestContext()` or `createOfflineEnv()`; that lane should cover the normal Images application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Images details. + +#### When this binding fits best + +##### Key points + +- Use Images when image transformation/upload paths where the worker calls the images binding. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockImagesBinding()` / `createMockEnv({ images })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Images internals** โ€” Check emitted Wrangler `images`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/images/internals)) +- **Testing Images** โ€” Pick the `createTestContext()` or `createOfflineEnv()` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/images/testing)) +- **Images example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/images/example)) + +--- + +### How Devflare wires Images from config to runtime + +> Images compiles from `bindings.images` to Wrangler `images`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/images/internals`](/docs/bindings/images/internals) | +| Group | Bindings | +| Navigation title | Images internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.images` before emitting Wrangler `images` | +| Compile target | Wrangler `images` | +| Preview note | Cloudflare owns hosted image storage, variants, delivery rules, billing, and final transform fidelity. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Images config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'images-worker', + bindings: { + images: { + IMAGES: true + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "images": { + "binding": "IMAGES" + } +} +``` + +#### What local runtime support covers + +##### Key points + +- Full local support through Miniflare image bindings, persisted local state, and deterministic pure mocks +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()`. +- Pure unit tests can use `createMockImagesBinding()` / `createMockEnv({ images })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `images` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns hosted image storage, variants, delivery rules, billing, and final transform fidelity. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Images docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.images`. + +##### Highlights + +- **Cloudflare Images docs** โ€” Platform reference for Images bindings, transformations, billing, and Workers API setup. ([link](https://developers.cloudflare.com/images/transform-images/bindings/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for Images bindings, transformations, billing, and Workers API setup. | How to author `bindings.images`, what the runtime surface looks like, and how Images fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support through Miniflare image bindings, persisted local state, and deterministic pure mocks. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Images the way Devflare expects it to run + +> Test Images by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/images/testing`](/docs/bindings/images/testing) | +| Group | Bindings | +| Navigation title | Testing Images | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | image transformation/upload paths where the Worker calls the Images binding | +| Default harness | `createTestContext()` or `createOfflineEnv()` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure Images chain-shape test + +```ts +import { expect, test } from 'bun:test' +import { createMockImagesBinding } from 'devflare/test' + +test('returns a deterministic image response', async () => { + const images = createMockImagesBinding() + const result = await images.input(new Blob(['image']).stream()).transform({ width: 320 }).output({ format: 'image/png' }) + + expect(result.response().headers.get('content-type')).toBe('image/png') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` for config-backed local worker tests. +- Use `createMockImagesBinding()` / `createMockEnv({ images })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns hosted image storage, variants, delivery rules, billing, and final transform fidelity. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Images, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Images in a real application path + +> A compact Images recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/images/example`](/docs/bindings/images/example) | +| Group | Bindings | +| Navigation title | Images example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.images | +| Runtime shape | `ImagesBinding` | +| Best use | image transformation/upload paths where the Worker calls the Images binding | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Images config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'images-worker', + bindings: { + images: { + IMAGES: true + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Images path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns hosted image storage, variants, delivery rules, billing, and final transform fidelity. + +##### Example โ€” Transform uploaded image bytes + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing image', { status: 400 }) + } + + return env.IMAGES + .input(request.body) + .transform({ width: 320 }) + .output({ format: 'image/jpeg' }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.images. +- Runtime shape: `ImagesBinding`. +- Best use: image transformation/upload paths where the Worker calls the Images binding. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Media Transformations in a Worker + +> Add the Media Transformations config, call `MediaBinding` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/media-transformations`](/docs/bindings/media-transformations) | +| Group | Bindings | +| Navigation title | Media Transformations | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.media` | +| Authoring shape | `Record` | +| Best for | video/audio transformation paths where the Worker calls Cloudflare Media Transformations | + +#### Add the binding to config + +Add `bindings.media` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Media Transformations config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'media-worker', + bindings: { + media: { + MEDIA: true + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Media Transformations path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Run one media transformation chain + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing media', { status: 400 }) + } + + return env.MEDIA + .input(request.body) + .transform({ width: 640 }) + .output({ format: 'video/mp4' }) +} +``` + +#### Local and Remote Support + +Devflare can run useful Media Transformations application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Full local support through Miniflare media bindings and deterministic pure mocks for transform chains. Start locally with `createTestContext()` or `createOfflineEnv()` with media fixtures; that lane should cover the normal Media Transformations application flow without requiring a Cloudflare connection. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Media Transformations details. + +#### When this binding fits best + +##### Key points + +- Use Media Transformations when video/audio transformation paths where the worker calls cloudflare media transformations. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createTestContext()` or `createOfflineEnv()` with media fixtures for config-backed local worker tests. +- Use `createMockMediaBinding()` / `createMockEnv({ media })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Media Transformations internals** โ€” Check emitted Wrangler `media`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/media-transformations/internals)) +- **Testing Media Transformations** โ€” Pick the `createTestContext()` or `createOfflineEnv()` with media fixtures path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/media-transformations/testing)) +- **Media Transformations example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/media-transformations/example)) + +--- + +### How Devflare wires Media Transformations from config to runtime + +> Media Transformations compiles from `bindings.media` to Wrangler `media`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/media-transformations/internals`](/docs/bindings/media-transformations/internals) | +| Group | Bindings | +| Navigation title | Media Transformations internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.media` before emitting Wrangler `media` | +| Compile target | Wrangler `media` | +| Preview note | Cloudflare owns real codecs, output fidelity, duration handling, cache behavior, and billing. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Media Transformations config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'media-worker', + bindings: { + media: { + MEDIA: true + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "media": { + "binding": "MEDIA" + } +} +``` + +#### What local runtime support covers + +##### Key points + +- Full local support through Miniflare media bindings and deterministic pure mocks for transform chains +- The default docs recipe uses `createTestContext()` or `createOfflineEnv()` with media fixtures. +- Pure unit tests can use `createMockMediaBinding()` / `createMockEnv({ media })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `media` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns real codecs, output fidelity, duration handling, cache behavior, and billing. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Media Transformations docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.media`. + +##### Highlights + +- **Cloudflare Media Transformations docs** โ€” Platform reference for Media Transformations bindings, beta limits, and Workers API setup. ([link](https://developers.cloudflare.com/stream/transform-videos/bindings/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for Media Transformations bindings, beta limits, and Workers API setup. | How to author `bindings.media`, what the runtime surface looks like, and how Media Transformations fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Full local support through Miniflare media bindings and deterministic pure mocks for transform chains. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Media Transformations the way Devflare expects it to run + +> Test Media Transformations by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/media-transformations/testing`](/docs/bindings/media-transformations/testing) | +| Group | Bindings | +| Navigation title | Testing Media Transformations | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | video/audio transformation paths where the Worker calls Cloudflare Media Transformations | +| Default harness | `createTestContext()` or `createOfflineEnv()` with media fixtures | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure Media chain-shape test + +```ts +import { expect, test } from 'bun:test' +import { createMockMediaBinding } from 'devflare/test' + +test('returns a deterministic media response', async () => { + const media = createMockMediaBinding() + const result = media.input(new Blob(['media']).stream()).transform({ width: 640 }).output({ mode: 'video' }) + const response = await result.response() + + expect(response.headers.get('content-type')).toBe('video/mp4') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createTestContext()` or `createOfflineEnv()` with media fixtures for config-backed local worker tests. +- Use `createMockMediaBinding()` / `createMockEnv({ media })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns real codecs, output fidelity, duration handling, cache behavior, and billing. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Media Transformations, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Media Transformations in a real application path + +> A compact Media Transformations recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/media-transformations/example`](/docs/bindings/media-transformations/example) | +| Group | Bindings | +| Navigation title | Media Transformations example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.media | +| Runtime shape | `MediaBinding` | +| Best use | video/audio transformation paths where the Worker calls Cloudflare Media Transformations | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Media Transformations config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'media-worker', + bindings: { + media: { + MEDIA: true + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Media Transformations path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns real codecs, output fidelity, duration handling, cache behavior, and billing. + +##### Example โ€” Run one media transformation chain + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(request: Request): Promise { + if (!request.body) { + return new Response('missing media', { status: 400 }) + } + + return env.MEDIA + .input(request.body) + .transform({ width: 640 }) + .output({ format: 'video/mp4' }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.media. +- Runtime shape: `MediaBinding`. +- Best use: video/audio transformation paths where the Worker calls Cloudflare Media Transformations. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Artifacts in a Worker + +> Add the Artifacts config, call `Artifacts` from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/artifacts`](/docs/bindings/artifacts) | +| Group | Bindings | +| Navigation title | Artifacts | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `bindings.artifacts` | +| Authoring shape | `Record` | +| Best for | Worker-managed repo metadata, temporary tokens, and artifact namespace workflows | + +#### Add the binding to config + +Add `bindings.artifacts` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Artifacts config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'artifact-worker', + bindings: { + artifacts: { + ARTIFACTS: 'build-artifacts' + } + } +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Artifacts path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Create one Artifacts repository + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const repo = await env.ARTIFACTS.create('run-logs', { + description: 'CI run logs' + }) + + return Response.json({ remote: repo.remote }) +} +``` + +#### Local and Remote Support + +Devflare supports the config, generated env shape, docs, and local application-flow work, but full fidelity requires Cloudflare remote infrastructure. Use local shims or fixtures for code your app owns, then connect to Cloudflare when the product behavior is the assertion. + +Offline-fixture: repo metadata and token flows can be modeled in memory, not as real Git remotes. Keep local coverage focused on deterministic application flow through fixtures, mocks, shims, or Miniflare-backed wiring instead of pretending to reproduce Cloudflare-hosted product behavior. + +Use Cloudflare when the assertion depends on cloudflare-hosted product behavior rather than the app calling the binding correctly. This is the lane for full Artifacts product fidelity, remote state, lifecycle behavior, and platform-specific limits. + +#### When this binding fits best + +##### Key points + +- Use Artifacts when worker-managed repo metadata, temporary tokens, and artifact namespace workflows. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `createOfflineEnv()` with artifact fixtures for config-backed local worker tests. +- Use `createMockArtifacts()` / `createMockEnv({ artifacts })` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Artifacts internals** โ€” Check emitted Wrangler `artifacts`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/artifacts/internals)) +- **Testing Artifacts** โ€” Pick the `createOfflineEnv()` with artifact fixtures path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/artifacts/testing)) +- **Artifacts example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/artifacts/example)) + +--- + +### How Devflare wires Artifacts from config to runtime + +> Artifacts compiles from `bindings.artifacts` to Wrangler `artifacts`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/artifacts/internals`](/docs/bindings/artifacts/internals) | +| Group | Bindings | +| Navigation title | Artifacts internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `bindings.artifacts` before emitting Wrangler `artifacts` | +| Compile target | Wrangler `artifacts` | +| Preview note | Cloudflare owns real Git protocol, durable namespace storage, permissions, and remote URLs. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Artifacts config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'artifact-worker', + bindings: { + artifacts: { + ARTIFACTS: 'build-artifacts' + } + } +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "artifacts": [ + { "binding": "ARTIFACTS", "namespace": "build-artifacts" } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-fixture: repo metadata and token flows can be modeled in memory, not as real Git remotes +- The default docs recipe uses `createOfflineEnv()` with artifact fixtures. +- Pure unit tests can use `createMockArtifacts()` / `createMockEnv({ artifacts })` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `artifacts` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns real Git protocol, durable namespace storage, permissions, and remote URLs. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Artifacts docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `bindings.artifacts`. + +##### Highlights + +- **Cloudflare Artifacts docs** โ€” Platform reference for Artifacts Workers bindings, repos, tokens, and namespace methods. ([link](https://developers.cloudflare.com/artifacts/api/workers-binding/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for Artifacts Workers bindings, repos, tokens, and namespace methods. | How to author `bindings.artifacts`, what the runtime surface looks like, and how Artifacts fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-fixture: repo metadata and token flows can be modeled in memory, not as real Git remotes. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Artifacts the way Devflare expects it to run + +> Test Artifacts by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/artifacts/testing`](/docs/bindings/artifacts/testing) | +| Group | Bindings | +| Navigation title | Testing Artifacts | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | Worker-managed repo metadata, temporary tokens, and artifact namespace workflows | +| Default harness | `createOfflineEnv()` with artifact fixtures | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Pure Artifacts repo test + +```ts +import { expect, test } from 'bun:test' +import { createMockArtifacts } from 'devflare/test' + +test('creates an in-memory artifact repo', async () => { + const artifacts = createMockArtifacts() + const repo = await artifacts.create('run-logs') + + expect(repo.name).toBe('run-logs') +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `createOfflineEnv()` with artifact fixtures for config-backed local worker tests. +- Use `createMockArtifacts()` / `createMockEnv({ artifacts })` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns real Git protocol, durable namespace storage, permissions, and remote URLs. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Artifacts, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Artifacts in a real application path + +> A compact Artifacts recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/artifacts/example`](/docs/bindings/artifacts/example) | +| Group | Bindings | +| Navigation title | Artifacts example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | bindings.artifacts | +| Runtime shape | `Artifacts` | +| Best use | Worker-managed repo metadata, temporary tokens, and artifact namespace workflows | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Artifacts config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'artifact-worker', + bindings: { + artifacts: { + ARTIFACTS: 'build-artifacts' + } + } +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Artifacts path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns real Git protocol, durable namespace storage, permissions, and remote URLs. + +##### Example โ€” Create one Artifacts repository + +```ts +import { env } from 'devflare/runtime' + +export async function fetch(): Promise { + const repo = await env.ARTIFACTS.create('run-logs', { + description: 'CI run logs' + }) + + return Response.json({ remote: repo.remote }) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: bindings.artifacts. +- Runtime shape: `Artifacts`. +- Best use: Worker-managed repo metadata, temporary tokens, and artifact namespace workflows. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. + +--- + +### Use Containers in a Worker + +> Add the Containers config, call Container class config plus a Durable Object container binding from worker code, and start with the local test path Devflare supports. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/containers`](/docs/bindings/containers) | +| Group | Bindings | +| Navigation title | Containers | +| Eyebrow | Binding reference | + +Start with the config, wire the binding into worker code, then use the support section to decide whether local tests or Cloudflare-backed tests fit. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config key | `containers` | +| Authoring shape | `Array<{ className; image; maxInstances?; instanceType?; imageBuildContext? }>` | +| Best for | routing requests to a stateful container instance that runs code outside the Workers runtime | + +#### Add the binding to config + +Add `containers` to `devflare.config.ts`, then use the generated env binding from Worker code. + +Keep the first version close to the route or handler that needs it; move to a helper only after the shape is obvious. + +##### Example โ€” Smallest Containers config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + files: { + fetch: 'src/container.api.ts', + durableObjects: 'src/container.api.ts' + }, + bindings: { + durableObjects: { + API_CONTAINER: { + className: 'ApiContainer' + } + } + }, + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ], + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ApiContainer'] + } + ] +}) +``` + +#### Use the binding from application code + +After Devflare generates the worker env, import `env` from `devflare/runtime` and keep the first Containers path close to the route, handler, or service method that needs it. + +Keep this first path small enough that the config, env binding, and user-visible behavior are easy to review together. + +##### Example โ€” Proxy one application route to a container instance + +```ts +import { Container, getContainer } from '@cloudflare/containers' +import { env } from 'devflare/runtime' + +export class ApiContainer extends Container { + defaultPort = 8080 + sleepAfter = '10m' +} + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const sessionId = url.searchParams.get('session') ?? 'public' + const container = getContainer(env.API_CONTAINER, sessionId) + + return container.fetch(request) +} +``` + +#### Local and Remote Support + +Devflare can run useful Containers application behavior locally for ordinary development and tests. Cloudflare still owns production limits, quotas, billing, and deployed account behavior. + +Containers have full local support when Docker or Podman is reachable and the image can be built or inspected without Cloudflare. Devflare builds Dockerfile paths offline-first, runs the container on loopback, and exposes fetch, logs, state, stop, and destroy helpers. Cloudflare still owns deployed rollout, registry availability, SSH, scaling, and hosted platform behavior. + +Use Cloudflare when the assertion depends on deployed limits, account state, lifecycle behavior, billing, or other production-only Containers details. + +#### Build and reference the image deliberately + +Devflare treats the `containers` entry as the contract between the Worker class and a real container image. For local work, point `image` at a tag that already exists in Docker or Podman, or point it at a local Dockerfile path that Devflare can build from files on disk. + +Cloudflare uses the same container idea in the hosted lane: Wrangler accepts a Dockerfile path or an image reference. Dockerfile paths are built locally and pushed during deploy, while image references can come from the Cloudflare Registry, Docker Hub, or Amazon ECR. + +##### Key points + +- Use `image: "./containers/api/Dockerfile"` or `image: "./containers/api"` when you want Wrangler deploy to build and push from source. +- Use `image: "localhost/devflare-api:latest"` for a local tag that Docker or Podman can inspect without a network pull. +- Use `registry.cloudflare.com//:` for Cloudflare Registry images, Docker Hub names such as `docker.io/library/nginx:alpine`, or Amazon ECR image references when the hosted deploy should pull a prebuilt image. +- Use `wrangler containers registries configure` when the image lives in a private external registry. + +##### Example โ€” Build the local image with Docker or Podman + +```bash +docker build -t localhost/devflare-api:latest ./containers/api +docker image inspect localhost/devflare-api:latest + +podman build -t localhost/devflare-api:latest ./containers/api +podman image inspect localhost/devflare-api:latest +``` + +##### Example โ€” Reference that local image from Devflare config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ] +}) +``` + +##### Example โ€” Use a Dockerfile or registry image for the Cloudflare lane + +```bash +wrangler containers build ./containers/api -t devflare-api:latest +wrangler containers push devflare-api:latest + +# Cloudflare can also reference registry images such as: +# registry.cloudflare.com//devflare-api:latest +# docker.io/library/nginx:alpine +# .dkr.ecr..amazonaws.com/devflare-api:latest +``` + +#### Full local support requirements + +Full local support means Devflare can build, launch, call, inspect, and clean up the container without Cloudflare when the local machine has a working Docker or Podman engine. + +The offline-first default is strict: Dockerfile builds use cached base layers, and image references must already exist locally. Set `offline: false` only when the test is allowed to pull from a registry. + +##### Key points + +- Install Docker or Podman and make sure `docker info` or `podman info` succeeds before running container tests. +- Set `DEVFLARE_CONTAINER_TESTS=1` for test lanes that are allowed to start local containers. +- Gate CI and hosted runners with `shouldSkip.containers` because GitHub Actions, Cloudflare runners, and preview workers may not expose a usable container engine. +- Keep base images cached when running offline. A missing local tag or uncached base layer is a setup problem, not a reason to silently reach out to a registry. + +##### Example โ€” Run a container-backed route test only when the engine is available + +```ts +import { afterAll, expect, test } from 'bun:test' +import { containers, shouldSkip } from 'devflare/test' + +const skipContainers = await shouldSkip.containers + +afterAll(() => containers.stopAll()) + +test.skipIf(skipContainers)('proxies to the local API container', async () => { + const api = await containers.start('ApiContainer', { + configPath: 'devflare.config.ts', + port: 8080, + offline: true + }) + + const response = await api.fetch('/health') + expect(response.status).toBe(200) +}) +``` + +#### When this binding fits best + +##### Key points + +- Use Containers when routing requests to a stateful container instance that runs code outside the workers runtime. +- Keep binding names stable and uppercase in examples so generated Env declarations remain predictable. +- Prefer Devflare native config while it covers the feature; use `wrangler.passthrough` only for unsupported Wrangler-only fields. + +#### Testing path + +##### Key points + +- Start with `devflare/test` containers helpers guarded by `shouldSkip.containers` for config-backed local worker tests. +- Use `detectContainerEngine()` / `createContainerManager()` / `containers` for small unit tests that only need deterministic application behavior. +- Use Cloudflare-backed tests when the assertion depends on hosted platform behavior, account state, limits, billing, or production routing. + +#### Open the next page when you need it + +##### Highlights + +- **Containers internals** โ€” Check emitted Wrangler `containers`, preview behavior, and Cloudflare-specific details. ([link](/docs/bindings/containers/internals)) +- **Testing Containers** โ€” Pick the `devflare/test` containers helpers guarded by `shouldSkip.containers` path first, then move to remote checks only when the test needs them. ([link](/docs/bindings/containers/testing)) +- **Containers example** โ€” Copy a fuller application path when the quick example is too small. ([link](/docs/bindings/containers/example)) + +--- + +### How Devflare wires Containers from config to runtime + +> Containers compiles from `containers` to Wrangler `containers`, with local/test behavior called out explicitly. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/containers/internals`](/docs/bindings/containers/internals) | +| Group | Bindings | +| Navigation title | Containers internals | +| Eyebrow | Under the hood | + +Use this page when you need emitted config, preview behavior, or Cloudflare-specific limits. The overview and example pages stay focused on everyday app code. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Normalization | Devflare normalizes `containers` before emitting Wrangler `containers` | +| Compile target | Wrangler `containers` | +| Preview note | Cloudflare owns deployed container rollout, managed registry availability, SSH, scaling, and hosted platform behavior; Devflare owns the Docker/Podman local loop. | + +#### How authored config becomes Wrangler config + +The authored config stays camelCase and project-oriented. The compiler translates that into the Wrangler keys Cloudflare expects. + +The emitted output is shown here so the usage pages do not have to explain compiler details. + +##### Example โ€” Containers config and emitted Wrangler output + +Use this when you need to check how the Devflare config becomes Wrangler-compatible config. + +###### File โ€” devflare.config.ts + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + files: { + fetch: 'src/container.api.ts', + durableObjects: 'src/container.api.ts' + }, + bindings: { + durableObjects: { + API_CONTAINER: { + className: 'ApiContainer' + } + } + }, + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ], + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ApiContainer'] + } + ] +}) +``` + +###### File โ€” .devflare/wrangler.jsonc + +```json +{ + "containers": [ + { "class_name": "ApiContainer", "image": "localhost/devflare-api:latest", "max_instances": 1 } + ], + "durable_objects": { + "bindings": [ + { "name": "API_CONTAINER", "class_name": "ApiContainer" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["ApiContainer"] } + ] +} +``` + +#### What local runtime support covers + +##### Key points + +- Offline-native only when an explicit Docker/Podman engine is available and the image can run without pulling +- The default docs recipe uses `devflare/test` containers helpers guarded by `shouldSkip.containers`. +- Pure unit tests can use `detectContainerEngine()` / `createContainerManager()` / `containers` when the test only needs deterministic application behavior. + +#### Compile, preview, and cleanup behavior + +##### Key points + +- Devflare emits Wrangler `containers` from the native config surface. +- Preview and deployment lifecycle stay feature-specific; do not assume all Cloudflare products can be created, cloned, or cleaned up the same way. +- Cloudflare owns deployed container rollout, managed registry availability, SSH, scaling, and hosted platform behavior; Devflare owns the Docker/Podman local loop. + +#### Cloudflare docs vs the Devflare layer + +Cloudflare Containers docs is the platform reference. Use this internals page when you need to compare Cloudflare's product docs with Devflare config, generated env types, local support, and preview behavior for `containers`. + +##### Highlights + +- **Cloudflare Containers docs** โ€” Platform reference for the Container class, container instances, and Worker interaction helpers. ([link](https://developers.cloudflare.com/containers/container-class/)) + +##### Reference table + +| Question | Cloudflare docs | This Devflare page | +| --- | --- | --- | +| Primary focus | Platform reference for the Container class, container instances, and Worker interaction helpers. | How to author `containers`, what the runtime surface looks like, and how Containers fits a Devflare project. | +| Testing and runtime lens | Cloudflareโ€™s docs focus on the raw binding API, product semantics, and platform limits for the binding itself. | Offline-native only when an explicit Docker/Podman engine is available and the image can run without pulling. Use the Devflare guidance when you need the honest local harness or the right remote gate instead of only the product API shape. | +| When to open it | When you need the platform contract, limits, APIs, or account-level product details. | When you are wiring, testing, previewing, or reviewing the binding inside a Devflare app. | + +--- + +### Test Containers the way Devflare expects it to run + +> Test Containers by choosing the local harness that matches the product boundary instead of reaching for Cloudflare by default. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/containers/testing`](/docs/bindings/containers/testing) | +| Group | Bindings | +| Navigation title | Testing Containers | +| Eyebrow | Testing | + +The first test should prove application control flow. Escalate to Wrangler remote binding or deployed tests only when the Cloudflare-hosted behavior is the thing under test. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Best for | routing requests to a stateful container instance that runs code outside the Workers runtime | +| Default harness | `devflare/test` containers helpers guarded by `shouldSkip.containers` | +| Escalate when | The assertion depends on Cloudflare-hosted product behavior rather than the app calling the binding correctly | + +#### Start with the default test loop + +Keep the first test small. Name the binding, call the one method your route uses, and assert the behavior your app owns. + +When Cloudflare owns the interesting behavior, mark that as a remote/deployed lane instead of building a local fake that claims too much. + +##### Example โ€” Detect Docker or Podman before running container tests + +```ts +import { expect, test } from 'bun:test' +import { detectContainerEngine } from 'devflare/test' + +test('container engine detection is explicit', async () => { + const status = await detectContainerEngine() + if (!status.available) { + expect(status.reason.length).toBeGreaterThan(0) + return + } + + expect(['docker', 'podman']).toContain(status.engine) +}) +``` + +#### The helper surface to remember + +##### Key points + +- Use `devflare/test` containers helpers guarded by `shouldSkip.containers` for config-backed local worker tests. +- Use `detectContainerEngine()` / `createContainerManager()` / `containers` for pure unit tests. +- Use `shouldSkip` or an explicit integration lane when the test needs Cloudflare credentials or a local Docker/Podman engine. + +#### When to move beyond the default harness + +##### Key points + +- Cloudflare owns deployed container rollout, managed registry availability, SSH, scaling, and hosted platform behavior; Devflare owns the Docker/Podman local loop. +- Do not let a low-fidelity mock become product documentation. Keep mocks framed as application-flow tools. +- If a test would mutate paid or remote Cloudflare state, gate it separately from ordinary unit tests. + +> **Warning โ€” Local tests should be honest** +> +> For Containers, passing locally means the Devflare contract and app flow are correct. It does not automatically prove every hosted Cloudflare behavior. + +--- + +### Use Containers in a real application path + +> A compact Containers recipe with config and worker usage in one application path. + +| Field | Value | +| --- | --- | +| Route | [`/docs/bindings/containers/example`](/docs/bindings/containers/example) | +| Group | Bindings | +| Navigation title | Containers example | +| Eyebrow | Application example | + +Use this as the copyable starter before threading the feature into a larger application. + +#### At a glance + +| Fact | Value | +| --- | --- | +| Config focus | containers | +| Runtime shape | Container class config plus a Durable Object container binding | +| Best use | routing requests to a stateful container instance that runs code outside the Workers runtime | + +#### Start by wiring the binding clearly in config + +##### Example โ€” Smallest Containers config + +```ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'container-worker', + files: { + fetch: 'src/container.api.ts', + durableObjects: 'src/container.api.ts' + }, + bindings: { + durableObjects: { + API_CONTAINER: { + className: 'ApiContainer' + } + } + }, + containers: [ + { + className: 'ApiContainer', + image: 'localhost/devflare-api:latest', + imageBuildContext: './containers/api', + maxInstances: 1 + } + ], + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ApiContainer'] + } + ] +}) +``` + +#### Build the application flow around the binding + +Treat this as the app-level Containers path: the route, event handler, or service module receives a real request and uses the binding to do useful work. + +Keep product limits, remote ownership, and fallback behavior visible in the code around the binding instead of hiding everything behind a vague utility too early. + +##### Key points + +- Keep the first example short enough to paste into a new Worker. +- Cloudflare owns deployed container rollout, managed registry availability, SSH, scaling, and hosted platform behavior; Devflare owns the Docker/Podman local loop. + +##### Example โ€” Proxy one application route to a container instance + +```ts +import { Container, getContainer } from '@cloudflare/containers' +import { env } from 'devflare/runtime' + +export class ApiContainer extends Container { + defaultPort = 8080 + sleepAfter = '10m' +} + +export async function fetch(request: Request): Promise { + const url = new URL(request.url) + const sessionId = url.searchParams.get('session') ?? 'public' + const container = getContainer(env.API_CONTAINER, sessionId) + + return container.fetch(request) +} +``` + +#### Keep production boundaries visible + +##### Key points + +- Config focus: containers. +- Runtime shape: Container class config plus a Durable Object container binding. +- Best use: routing requests to a stateful container instance that runs code outside the Workers runtime. + +> **Important โ€” Thread this into the next recipe** +> +> Once this smallest path works, add routing, generated types, and feature-specific abstraction in that order. diff --git a/packages/devflare/README.md b/packages/devflare/README.md new file mode 100644 index 0000000..a061645 --- /dev/null +++ b/packages/devflare/README.md @@ -0,0 +1,383 @@ +# Devflare + +Devflare is a developer-first toolkit for Cloudflare Workers. It keeps config, +runtime helpers, local development, testing, preview deploys, and Cloudflare +operations in one package without pretending Cloudflare boundaries disappear. + +The docs app is the authored long-form source. This README is intentionally the +short package map: install, first Worker, import/API map, config and CLI +surface, support stances, and links into the deeper docs. `LLM.md` is generated +from the same docs model and shipped with the package for flattened reading. + +## Install + +For a worker-only project, install only Devflare: + +```bash +bun add -d devflare +``` + +For Vite-backed apps, add Vite. Add the Cloudflare Vite plugin only when your +own `vite.config.*` calls it directly: + +```bash +bun add -d devflare vite +``` + +Assumptions used by the examples: Wrangler 4, Miniflare 4, +`@cloudflare/workers-types` 4, Bun 1.1+, and Node 20+. + +## Cloudflare toolchain support + +Devflare targets Wrangler 4, Miniflare 4, and @cloudflare/workers-types 4. +Devflare does not support Wrangler 3 in new projects. The package manifest pins +the exact ranges that scaffolds, local runtime behavior, and generated types +are validated against in CI. + +## Quick Start + +Create a config: + +```ts +// devflare.config.ts +import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'hello-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +}) +``` + +Add a fetch handler: + +```ts +// src/fetch.ts +import type { FetchEvent } from 'devflare/runtime' + +export async function fetch(_event: FetchEvent): Promise { + return new Response('Hello from Devflare') +} +``` + +Generate types and start local dev: + +```bash +bunx --bun devflare types +bunx --bun devflare dev +``` + +Add the first runtime-shaped test: + +```ts +// tests/worker.test.ts +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { cf, createTestContext, env } from 'devflare/test' + +beforeAll(() => createTestContext()) +afterAll(() => env.dispose()) + +test('GET / returns text', async () => { + const response = await cf.worker.get('/') + + expect(response.status).toBe(200) + expect(await response.text()).toBe('Hello from Devflare') +}) +``` + +Run it: + +```bash +bun test tests/worker.test.ts +``` + +## Choose Your Next Path + +| Need | Open | +| --- | --- | +| Add route files | `/docs/first-route-tree`, then `/docs/http-routing` | +| Add one binding | `/docs/first-bindings`, then `/docs/binding-chooser` | +| Pick a test helper | `/docs/test-helper-reference` | +| Deploy safely | `/docs/deploy-command-recipes` | +| Check support stance | `/docs/feature-index` | +| Copy a larger example | `/docs/recipe-packs` or `cases/README.md` | + +## Package Entrypoints + +| Import | Use | +| --- | --- | +| `devflare` | Node-side utilities: `defineConfig`, `preview`, `loadConfig`, `loadResolvedConfig`, `compileConfig`, `stringifyConfig`, `configSchema`, `ref()`, `workerName`, `env`, `durableObject`, `getDurableObjectOptions`, `runCli`, `parseArgs` | +| `devflare/config` | Config and compiler utilities: `defineConfig`, `preview`, `ref`, `loadConfig`, `loadResolvedConfig`, `compileConfig`, `stringifyConfig`, `configSchema`, `resolveResources`, `writeWranglerConfig`, `readWranglerConfig`, `prepareConfigResourcesForDeploy`, `prepareMaterializedConfigResourcesForDeploy`, `resolveConfigPath`, `resolveConfigForEnvironment`, `resolvePreviewIdentifier`, `materializePreviewScopedConfig`, `materializePreviewScopedString`, `isPreviewScopedName`, `resolveMaterializedConfigResources`, `compileBuildConfig`, `validateServiceBindings`, `collectReferencedServiceNames`, `getLocalKVNamespaceIdentifier`, `getLocalD1DatabaseIdentifier`, `getLocalHyperdriveConfigIdentifier`, `getSingleBrowserBindingName`, `normalizeKVBinding`, `normalizeD1Binding`, `normalizeDOBinding`, `normalizeHyperdriveBinding`, `normalizeMtlsCertificateBinding`, `normalizeDispatchNamespaceBinding`, `normalizeWorkflowBinding`, `normalizePipelineBinding`, `normalizeImagesBinding`, `normalizeMediaBinding`, `normalizeSecretsStoreBinding`, `normalizeArtifactsBinding` | +| `devflare/runtime` | Worker-safe runtime helpers: `env`, `ctx`, `event`, `locals`, `sequence`, `defineFetchHandler`, `defineQueueHandler`, `defineScheduledHandler`, `markResolveStyle`, `markWorkerStyle`, `createResolveFetch`, `invokeFetchHandler`, `invokeFetchModule`, `matchFetchRoute`, `invokeRouteModules`, `createRouteResolve`, event creators and getters | +| `devflare/test` | Testing helpers: `createTestContext`, `env`, `cf`, `worker`, `queue`, `scheduled`, `email`, `tail`, `shouldSkip`, `createOfflineEnv`, `createOfflineBindings`, `describeOfflineSupport`, `getOfflineSupportMatrix`, `containers`, `detectContainerEngine`, `getContainerSkipReason`, `stopActiveContainers`, `createMockEnv`, `createMockKV`, `createMockD1`, `createMockR2`, `createMockQueue`, `createMockRateLimit`, `createMockVersionMetadata`, `createMockHyperdrive`, `createMockWorkerLoader`, `createMockMTLSCertificate`, `createMockDispatchNamespace`, `createMockWorkflow`, `createMockPipeline`, `createMockImagesBinding`, `createMockMediaBinding`, `createMockArtifacts`, `createMockAISearchInstance`, `createMockAISearchNamespace`, `createMockTestContext`, `withTestContext`, `resolveServiceBindings`, `resolveDOBindings`, `clearBundleCache` | +| `devflare/vite` | Vite integration: `devflarePlugin`, `getCloudflareConfig`, `getDevflareConfigs`, `getPluginContext`, `hasInlineViteConfig`, `resolveEffectiveViteProject`, `resolveViteUserConfig`, `writeGeneratedViteConfig` | +| `devflare/sveltekit` | SvelteKit integration: `createDevflarePlatform`, `createHandle`, `handle`, `getBridgePort`, `isDevflareDev`, `resetPlatform`, `resetConfigCache` | +| `devflare/cloudflare` | Cloudflare account and preview registry helpers: `account`, `ensurePreviewRegistry`, `cleanupPreviewRegistry`, `getPreviewRegistryContext`, `listTrackedRegistryState`, `listTrackedPreviewRecords`, `listTrackedPreviewScopeRecords`, `listTrackedDeploymentRecords`, `reconcilePreviewRegistry`, `retirePreviewRegistry` | +| `devflare/decorators` | Durable Object decorators: `durableObject`, `getDurableObjectOptions` | + +Runtime import rule of thumb: + +- Use `devflare/config` in config files. +- Use `devflare/runtime` in Worker code. +- Use `devflare/test` in tests. +- Use bare `devflare` for Node-side package tooling and the unified env proxy only when that is intentional. + +## Config Map + +The most important top-level keys are: + +- `accountId` +- `assets` +- `baseDir` +- `bindings` +- `compatibilityDate` +- `compatibilityFlags` +- `containers` +- `env` +- `files` +- `findAdditionalModules` +- `limits` +- `migrations` +- `name` +- `observability` +- `placement` +- `preserveFileNames` +- `previews` +- `rolldown` +- `routes` +- `rules` +- `secrets` +- `secretsStoreId` +- `tailConsumers` +- `triggers` +- `vars` +- `vite` +- `wrangler.passthrough` +- `wsRoutes` + +Open `/docs/full-config`, `/docs/config-basics`, and `/docs/generated-types` +for examples with file paths. + +## CLI + +| Command | Use | +| --- | --- | +| `devflare account` | inspect Cloudflare account resources, limits, and usage | +| `devflare ai` | inspect Workers AI model pricing information | +| `devflare build` | generate deploy-ready local artifacts | +| `devflare config` | print resolved Devflare or Wrangler-facing config | +| `devflare deploy` | deploy explicitly to production or preview | +| `devflare dev` | start local development | +| `devflare doctor` | inspect local project health | +| `devflare help` | print help for root or nested commands | +| `devflare init` | scaffold a starter project | +| `devflare login` | authenticate through Wrangler | +| `devflare previews` | inspect and clean preview scopes | +| `devflare productions` | inspect or manage production Worker versions | +| `devflare remote` | manage remote test mode | +| `devflare secrets` | manage local Secrets Store values | +| `devflare tokens` | create and manage Devflare-scoped API tokens | +| `devflare types` | generate `env.d.ts` | +| `devflare version` | print the installed version | +| `devflare worker` | run Worker control-plane helpers | + +Useful local-first switches: + +- `devflare dev --runtime-port 8788` or `DEVFLARE_RUNTIME_PORT=8788` moves the local Miniflare runtime/bridge off the default `127.0.0.1:8787`. `--bridge-port` and `DEVFLARE_BRIDGE_PORT` are aliases for the same runtime port. +- `devflare doctor --scope local` skips deploy-readiness artifact warnings during a local-only loop. +- `devflare config --phase local --format wrangler` prints the local-runtime Wrangler shape without Cloudflare account resource lookups. +- `devflare dev` and `devflare/vite` start `ref()` service-binding workers inside the same local runtime, including their local KV, D1, R2, Queue producer/consumer, vars, Durable Objects, and other Miniflare-backed bindings. + +## Support Stance Index + +The full support matrix is in `/docs/feature-index`. The short version is: +offline-first when deterministic local behavior is meaningful, remote-gated +when Cloudflare owns the product behavior, and explicit skip helpers when CI +cannot safely run the dependency. + +### AutoRAG migration stance + +AutoRAG is documented under AI Search. The previous `env.AI.autorag()` binding +shape should move to native AI Search config. Use `bindings.aiSearchNamespaces` +or `bindings.aiSearch` so the binding is visible in config, generated types, +and tests. + +### AI Gateway binding methods + +AI Gateway does not use a separate Wrangler binding. It is a method surface on +Workers AI: `env.AI.gateway(id)` exposes `patchLog()`, `getLog()`, `getUrl()`, +and `run()`. + +### Browser Run product boundary + +Browser Run is the current product name for Browser Rendering. Devflare runs a +local browser-rendering shim for the ordinary dev and test loop, but Devflare +does not manage Live View URLs, Human in the Loop handoff, recordings, browser +session storage, or Browser Run account-level product state. + +### Containers local testing + +Devflare supports native top-level `containers` config and local container +testing helpers. Containers have full local support when Docker or Podman is +available: Devflare can build local Dockerfile paths, run prebuilt image tags, +and interact with instances through fetch, logs, state, stop, and cleanup +helpers. Devflare container tests are offline-first by default when the image +already exists locally or the Dockerfile can build from cached layers. Set +`DEVFLARE_CONTAINER_TESTS=1` for container lanes, and gate them with +`shouldSkip.containers` because GitHub Actions or Cloudflare runners may not +have Docker/Podman. Cloudflare still owns the deployed Containers control plane, +managed registry rollout, SSH, scaling, and hosted platform behavior. + +### Cloudflare Builds stance + +Cloudflare Builds is CI/CD orchestration, not a Worker runtime binding. +Devflare does not connect Git repositories, manage build hooks, own Cloudflare +Builds project settings, or replace GitHub Actions workflows. + +### Workers for Platforms lifecycle stance + +Devflare supports dispatch namespace bindings, not the tenant Worker control +plane. Devflare does not upload user Workers, manage Worker metadata, own tenant +routing policy, or provide the Workers for Platforms lifecycle API. + +### Hyperdrive local connection stance + +Hyperdrive has full local support when a binding has a local database connection +string. Devflare passes `localConnectionString` or +`CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_` into Miniflare and +also exposes `createMockHyperdrive()` for pure tests. Cloudflare still owns +hosted pooling, placement, production credentials, and account state. + +### Worker Loaders local payload stance + +Worker Loaders have full local support through Miniflare worker loader bindings +and explicit pure-test stubs. Devflare does not upload, discover, or lifecycle +manage hosted dynamic Worker payloads. + +### Secrets Store local values stance + +Secrets Store has full local support through Miniflare wiring, local +`devflare secrets --local` values, and explicit fixture values in +`createOfflineEnv()` or `createMockSecretsStoreSecret()`. The local runtime is +read-only from Worker code: write values through the CLI, keep config to store +IDs and secret names, and let dev/test runs seed Miniflare from +`.devflare/secrets.local.json`. Devflare does not read, provision, or sync +remote account secret values. + +### Workflows local simulation stance + +Workflows have full local support through Miniflare wiring, WorkflowEntrypoint +examples, and deterministic pure mocks. Use deployed or Wrangler-backed tests +for production Workflow lifecycle behavior, retries, durability, and platform +scheduling. + +### Pipelines source and sink lifecycle stance + +Pipelines local tests are useful for producer-code assertions. Devflare does not +create streams, pipelines, SQL transformations, sinks, or R2 buckets for the +deployed product lifecycle. + +### Images transformation testability stance + +Images have full local support for Worker transformation flows through +Miniflare wiring and deterministic pure mocks. Devflare does not provision +hosted Images storage, variants, signed URLs, or custom delivery rules. + +### Media Transformations local shim stance + +Media Transformations have full local support for Worker call chains through +Miniflare wiring and deterministic pure mocks. Devflare does not configure +zone-level transformation enablement, source origins, signed URL policy, cache +behavior, or billing controls. + +### Artifacts persistence and deployment stance + +Artifacts pure mocks are in-memory and process-local. Devflare does not create +Artifacts namespaces, persist local Git repositories, or emulate Git-over-HTTPS +remotes. + +### Preview resource lifecycle policy + +Devflare preview provisioning is intentionally limited to KV, D1, R2, Queues, +Vectorize, and the documented Hyperdrive reuse/resolve paths. Preview cleanup +does not delete Workflows, Pipelines, Images, Media Transformations, Artifacts, +AI Search, AI Gateway, Browser Run, Containers, Secrets Store, mTLS +certificates, or dispatch namespace resources. + +### Cross-feature implementation decisions + +Remote mode decisions are per feature, not global. Generated types are emitted +only for native binding keys. Test helpers exist when Devflare provides a +deterministic local mock or useful pure assertion surface. Every native binding +documented above includes a minimal config and Env usage example. Move from +`wrangler.passthrough` to native config when a binding appears in the native +list. Cloudflare dependency CI targets the pinned current Wrangler, Miniflare, +and workers-types majors documented in Cloudflare toolchain support. + +### Offline-first testing support matrix + +`createOfflineEnv(config, fixtures)` derives a deterministic pure-test `env` +from Devflare config. Offline-native means Devflare or Miniflare can run a +useful local simulator. Offline-fixture means Devflare provides an explicit +in-memory or handler-backed mock. Remote-boundary means meaningful behavior +lives in Cloudflare. + +Use `shouldSkip.aiSearch`, `shouldSkip.aiGateway`, +`shouldSkip.mtlsCertificates`, `shouldSkip.artifacts`, and `shouldSkip.builds` +for remote-boundary lanes. Offline-first tests should not claim to cover real +Workers AI inference, Vectorize search semantics, AI Search indexing/ranking/ +crawling, final Media Transformations codec fidelity, mTLS certificate +presentation, Artifacts Git remotes, Browser Run live/HITL/recordings, +Cloudflare Builds, or the deployed Containers control plane. + +## Machine-Checked Support Statements + +These statements are intentionally exact because the docs tests use them as +public stance guards: + +- Use `bindings.aiSearchNamespaces` or `bindings.aiSearch`. +- `env.AI.gateway(id)` exposes `patchLog()`, `getLog()`, `getUrl()`, and `run()`. +- Devflare does not manage Live View URLs, Human in the Loop handoff. +- Set `DEVFLARE_CONTAINER_TESTS=1`. +- Containers have full local support when Docker or Podman is available. +- Cloudflare still owns the deployed Containers control plane. +- Devflare does not connect Git repositories, manage build hooks. +- Devflare supports dispatch namespace bindings, not the tenant Worker control plane. +- Devflare does not upload user Workers, manage Worker metadata. +- Hyperdrive has full local support when a binding has a local database connection string. +- Worker Loaders have full local support through Miniflare worker loader bindings. +- Secrets Store has full local support through Miniflare wiring, local `devflare secrets --local` values, and explicit fixture values. +- Workflows have full local support through Miniflare wiring. +- Use deployed or Wrangler-backed tests for production Workflow lifecycle behavior. +- Devflare does not create streams, pipelines, SQL transformations, sinks, or R2 buckets. +- Images have full local support for Worker transformation flows. +- Devflare does not provision hosted Images storage, variants, signed URLs, or custom delivery rules. +- Media Transformations have full local support for Worker call chains. +- Devflare does not configure zone-level transformation enablement, source origins, signed URL policy, cache behavior, or billing controls. +- Devflare does not create Artifacts namespaces, persist local Git repositories, or emulate Git-over-HTTPS remotes. +- Devflare preview provisioning is intentionally limited to KV, D1, R2, Queues, Vectorize, and the documented Hyperdrive reuse/resolve paths. +- Preview cleanup does not delete Workflows, Pipelines, Images, Media Transformations, Artifacts, AI Search, AI Gateway, Browser Run, Containers, Secrets Store, mTLS certificates, or dispatch namespace resources. +- Generated types are emitted only for native binding keys. +- Test helpers exist when Devflare provides a deterministic local mock or useful pure assertion surface. +- Every native binding documented above includes a minimal config and Env usage example. +- Move from `wrangler.passthrough` to native config when a binding appears in the native list. +- Cloudflare dependency CI targets the pinned current Wrangler, Miniflare, and workers-types majors documented in Cloudflare toolchain support. +- `createOfflineEnv(config, fixtures)` derives a deterministic pure-test `env` from Devflare config. +- Offline-native means Devflare or Miniflare can run a useful local simulator. +- Offline-fixture means Devflare provides an explicit in-memory or handler-backed mock. +- Remote-boundary means meaningful behavior lives in Cloudflare. +- `shouldSkip.aiSearch`, `shouldSkip.aiGateway`, `shouldSkip.mtlsCertificates`, `shouldSkip.artifacts`, and `shouldSkip.builds`. +- real Workers AI inference, Vectorize search semantics, AI Search indexing/ranking/crawling, final Media Transformations codec fidelity, mTLS certificate presentation, Artifacts Git remotes, Browser Run live/HITL/recordings, Cloudflare Builds, or the deployed Containers control plane. + +## Verification + +Docs and README drift are covered by: + +```bash +bun run devflare:docs-integrity +``` + +The package publish path regenerates `packages/devflare/LLM.md` from the docs +model through `bun run --cwd packages/devflare llm:generate`. diff --git a/packages/devflare/bin/devflare.js b/packages/devflare/bin/devflare.js new file mode 100644 index 0000000..4ffff47 --- /dev/null +++ b/packages/devflare/bin/devflare.js @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const currentDir = dirname(fileURLToPath(import.meta.url)) +const sourceCliEntryPath = resolve(currentDir, '../src/cli/index.ts') +const distCliEntryPath = resolve(currentDir, '../dist/cli/index.js') +const cliEntryPath = existsSync(sourceCliEntryPath) + ? sourceCliEntryPath + : distCliEntryPath + +const { runCli } = await import(pathToFileURL(cliEntryPath).href) + +const args = process.argv.slice(2) + +runCli(args) + .then((result) => { + process.exit(result.exitCode) + }) + .catch((error) => { + console.error('CLI error:', error) + process.exit(1) + }) diff --git a/packages/devflare/package.json b/packages/devflare/package.json new file mode 100644 index 0000000..2afaa63 --- /dev/null +++ b/packages/devflare/package.json @@ -0,0 +1,141 @@ +{ + "name": "devflare", + "version": "1.0.0-next.27", + "description": "Devflare is a developer-first toolkit for Cloudflare Workers that sits on top of Miniflare and Wrangler-compatible config", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./dist/browser.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./config": { + "types": "./dist/config-entry.d.ts", + "import": "./dist/config-entry.js", + "default": "./dist/config-entry.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js", + "default": "./dist/runtime/index.js" + }, + "./test": { + "types": "./dist/test/index.d.ts", + "import": "./dist/test/index.js", + "default": "./dist/test/index.js" + }, + "./vite": { + "types": "./dist/vite/index.d.ts", + "import": "./dist/vite/index.js", + "default": "./dist/vite/index.js" + }, + "./sveltekit": { + "types": "./dist/sveltekit/index.d.ts", + "import": "./dist/sveltekit/index.js", + "default": "./dist/sveltekit/index.js" + }, + "./cloudflare": { + "types": "./dist/cloudflare/index.d.ts", + "import": "./dist/cloudflare/index.js", + "default": "./dist/cloudflare/index.js" + }, + "./decorators": { + "types": "./dist/decorators/index.d.ts", + "import": "./dist/decorators/index.js", + "default": "./dist/decorators/index.js" + }, + "./internal/send-email": { + "types": "./dist/utils/send-email.d.ts", + "import": "./dist/utils/send-email.js", + "default": "./dist/utils/send-email.js" + } + }, + "bin": { + "devflare": "./bin/devflare.js" + }, + "files": ["dist", "bin", "LLM.md"], + "scripts": { + "llm:generate": "bun ./scripts/generate-llm.ts", + "refresh-permission-groups": "bun ./scripts/refresh-permission-groups.ts", + "clean:dist": "bun ./scripts/clean-dist.ts", + "build": "bun run clean:dist && bun build ./src/index.ts ./src/browser.ts ./src/config-entry.ts ./src/cli/index.ts ./src/runtime/index.ts ./src/test/index.ts ./src/vite/index.ts ./src/sveltekit/index.ts ./src/cloudflare/index.ts ./src/decorators/index.ts ./src/utils/send-email.ts --root ./src --outdir ./dist --splitting --target node --packages=external && tsgo --declaration --emitDeclarationOnly --noEmit false --outDir ./dist", + "dev": "bun --watch ./src/cli/index.ts", + "prepack": "bun run llm:generate", + "test": "bun run test:unit && bun run test:integration:control && bun run test:integration:bridge && bun run test:integration:test-context && bun run test:integration:dev-server", + "test:unit": "bun test tests/unit", + "test:integration:control": "bun test tests/integration/cli tests/integration/examples tests/integration/package-entry tests/integration/vite", + "test:integration:bridge": "bun test tests/integration/bridge/bridge-proxy.test.ts --parallel=1 --max-concurrency=1 && bun test tests/integration/bridge/case18-do.test.ts --parallel=1 --max-concurrency=1 && bun test tests/integration/bridge/durable-object.test.ts --parallel=1 --max-concurrency=1 && bun test tests/integration/bridge/miniflare.test.ts --parallel=1 --max-concurrency=1 && bun test tests/integration/bridge/r2-transfer.test.ts --parallel=1 --max-concurrency=1", + "test:integration:test-context": "bun test tests/integration/test-context --parallel=1 --max-concurrency=1", + "test:integration:dev-server": "bun test tests/integration/dev-server --parallel=1 --max-concurrency=1", + "test:watch": "bun test --watch", + "typecheck": "tsgo --noEmit", + "types": "bun run typecheck", + "check": "bun run typecheck" + }, + "dependencies": { + "@puppeteer/browsers": "^2.10.3", + "c12": "^2.0.1", + "chokidar": "^4.0.3", + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "es-module-lexer": "^1.6.0", + "execa": "^9.5.2", + "fast-glob": "^3.3.3", + "globby": "^16.1.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^0.30.17", + "miniflare": "^4.20260424.0", + "pathe": "^2.0.2", + "picomatch": "^4.0.3", + "puppeteer-core": "^24.5.0", + "rolldown": "^1.0.0-rc.12", + "smol-toml": "^1.6.1", + "wrangler": "^4.85.0", + "ws": "^8.19.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260426.1", + "@types/bun": "^1.1.14", + "@types/picomatch": "^4.0.2", + "@types/ws": "^8.18.1", + "typescript": "^5.7.2", + "vite": "^6.0.0" + }, + "peerDependencies": { + "@cloudflare/vite-plugin": "^1.0.0", + "@cloudflare/workers-types": "^4.20260426.1", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@cloudflare/vite-plugin": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "vite": { + "optional": true + } + }, + "engines": { + "node": ">=20", + "bun": ">=1.1" + }, + "keywords": [ + "cloudflare", + "workers", + "wrangler", + "config", + "cli", + "durable-objects", + "middleware" + ], + "author": "", + "license": "MIT" +} diff --git a/packages/devflare/scripts/clean-dist.ts b/packages/devflare/scripts/clean-dist.ts new file mode 100644 index 0000000..8f94a13 --- /dev/null +++ b/packages/devflare/scripts/clean-dist.ts @@ -0,0 +1,6 @@ +import { rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) +await rm(resolve(packageRoot, 'dist'), { recursive: true, force: true }) diff --git a/packages/devflare/scripts/generate-llm.ts b/packages/devflare/scripts/generate-llm.ts new file mode 100644 index 0000000..34e1202 --- /dev/null +++ b/packages/devflare/scripts/generate-llm.ts @@ -0,0 +1,29 @@ +import { copyFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { generateLLMDocuments, getDocumentationStaticDir } from '../../../apps/documentation/scripts/llm-documents' + +const PACKAGE_LLM_FILE_NAME = 'LLM.md' + +function getPackageRootDir(): string { + const scriptDir = dirname(fileURLToPath(import.meta.url)) + return resolve(scriptDir, '..') +} + +async function main(): Promise { + const documentationStaticDir = getDocumentationStaticDir() + const packageRootDir = getPackageRootDir() + const packageLlmFile = resolve(packageRootDir, PACKAGE_LLM_FILE_NAME) + + const result = await generateLLMDocuments({ + outputDirs: [documentationStaticDir] + }) + + await copyFile(resolve(documentationStaticDir, PACKAGE_LLM_FILE_NAME), packageLlmFile) + + console.log( + `Generated ${result.outputFiles.join(', ')} in ${documentationStaticDir} and copied ${PACKAGE_LLM_FILE_NAME} to ${packageLlmFile}.` + ) +} + +await main() \ No newline at end of file diff --git a/packages/devflare/scripts/refresh-permission-groups.ts b/packages/devflare/scripts/refresh-permission-groups.ts new file mode 100644 index 0000000..3b01954 --- /dev/null +++ b/packages/devflare/scripts/refresh-permission-groups.ts @@ -0,0 +1,283 @@ +// ============================================================================= +// scripts/refresh-permission-groups.ts +// ============================================================================= +// Maintainer-run script that fetches Cloudflare permission groups for an +// authenticated account and rewrites +// `src/cloudflare/known-permission-group-ids.generated.ts` so the symbolic +// Devflare permission-group names map to verified Cloudflare UUIDs instead +// of falling back to display-name matching. +// +// Run with: +// bun run --cwd packages/devflare refresh-permission-groups +// +// Required environment variables: +// CLOUDFLARE_API_TOKEN โ€” token with permission to read +// /accounts/:id/tokens/permission_groups +// CLOUDFLARE_ACCOUNT_ID โ€” account id to query +// +// Optional environment variables: +// DEVFLARE_PERMISSION_GROUP_OUTPUT +// โ€” override output file path (defaults to the +// generated file inside src/cloudflare) +// DEVFLARE_PERMISSION_GROUP_DRY_RUN=1 +// โ€” print the would-be content to stdout instead +// of writing to disk; useful for CI drift checks +// +// The script never throws away verified ids when an entry is missing from +// the API response: a missing entry stays `null` (or keeps its previous +// value if --keep-existing is passed), and a console warning surfaces the +// drift so it can be reviewed. +// ============================================================================= + +import { writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { listAccountTokenPermissionGroups } from '../src/cloudflare/tokens' +import { KNOWN_PERMISSION_GROUP_DISPLAY_NAMES } from '../src/cloudflare/tokens' +import { KNOWN_PERMISSION_GROUP_IDS_DATA } from '../src/cloudflare/known-permission-group-ids.generated' + +interface RefreshOptions { + accountId: string + apiToken: string + outputPath: string + dryRun: boolean + keepExisting: boolean +} + +function readRequiredEnv(name: string): string { + const value = process.env[name] + if (!value || value.trim().length === 0) { + throw new Error( + `Missing required environment variable ${name}. ` + + 'Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID before running this script.' + ) + } + return value.trim() +} + +function parseCliFlags(argv: readonly string[]): { + dryRun: boolean + keepExisting: boolean + outputOverride?: string +} { + let dryRun = process.env.DEVFLARE_PERMISSION_GROUP_DRY_RUN === '1' + let keepExisting = false + let outputOverride: string | undefined + + for (let index = 0;index < argv.length;index++) { + const arg = argv[index] + switch (arg) { + case '--dry-run': + dryRun = true + break + case '--keep-existing': + keepExisting = true + break + case '--output': { + const next = argv[index + 1] + if (!next) { + throw new Error('--output requires a path argument.') + } + outputOverride = next + index++ + break + } + default: + if (arg.startsWith('--')) { + throw new Error(`Unknown flag ${arg}. Supported: --dry-run, --keep-existing, --output .`) + } + } + } + + return { dryRun, keepExisting, outputOverride } +} + +function getDefaultOutputPath(): string { + const scriptPath = fileURLToPath(import.meta.url) + const scriptDir = dirname(scriptPath) + return resolve( + scriptDir, + '..', + 'src', + 'cloudflare', + 'known-permission-group-ids.generated.ts' + ) +} + +interface ResolvedPermissionEntry { + symbolicName: keyof typeof KNOWN_PERMISSION_GROUP_DISPLAY_NAMES + displayName: string + previousId: string | null + resolvedId: string | null +} + +function resolveUpdatedEntries( + apiPermissionGroups: ReadonlyArray<{ id: string; name: string }>, + options: { keepExisting: boolean } +): ResolvedPermissionEntry[] { + const idsByDisplayName = new Map() + for (const group of apiPermissionGroups) { + // Cloudflare may return multiple permission groups with the same display + // name across scopes. The first one wins, which matches how the existing + // matcher behaves: the caller is expected to filter by scope before + // reaching this lookup, and the symbolic Devflare names target + // account-scoped groups. + if (!idsByDisplayName.has(group.name)) { + idsByDisplayName.set(group.name, group.id) + } + } + + const symbolicNames = Object.keys( + KNOWN_PERMISSION_GROUP_DISPLAY_NAMES + ) as Array + + return symbolicNames.map((symbolicName) => { + const displayName = KNOWN_PERMISSION_GROUP_DISPLAY_NAMES[symbolicName] + const previousId = KNOWN_PERMISSION_GROUP_IDS_DATA[symbolicName] + const fetchedId = idsByDisplayName.get(displayName) ?? null + const resolvedId = fetchedId ?? (options.keepExisting ? previousId : null) + + return { + symbolicName, + displayName, + previousId, + resolvedId + } + }) +} + +function renderGeneratedFile(entries: ResolvedPermissionEntry[]): string { + const typeBody = entries.map((entry) => `\t${entry.symbolicName}: string | null`).join('\n') + const dataBody = entries + .map((entry) => { + const value = entry.resolvedId === null ? 'null' : `'${entry.resolvedId.replace(/'/g, "\\'")}'` + return `\t${entry.symbolicName}: ${value}` + }) + .join(',\n') + + return `// ============================================================================= +// AUTO-GENERATED FILE โ€” Do not edit by hand. +// +// Regenerate with: +// bun run --cwd packages/devflare refresh-permission-groups +// +// Source of truth: +// GET /accounts/:id/tokens/permission_groups (Cloudflare API) +// +// Each entry maps a Devflare symbolic permission-group name to the +// authoritative Cloudflare permission-group UUID, or \`null\` when no +// verified UUID is known yet (in which case \`tokens.ts\` falls back to +// exact display-name matching with a console.warn). +// ============================================================================= + +export const KNOWN_PERMISSION_GROUP_IDS_DATA: { +${typeBody} +} = { +${dataBody} +} +` +} + +function reportDrift(entries: ResolvedPermissionEntry[]): void { + const newlyResolved = entries.filter((entry) => entry.previousId === null && entry.resolvedId !== null) + const stillMissing = entries.filter((entry) => entry.resolvedId === null) + const changed = entries.filter((entry) => entry.previousId !== null && entry.resolvedId !== null && entry.previousId !== entry.resolvedId) + + if (newlyResolved.length > 0) { + console.log(`[refresh-permission-groups] Newly resolved (${newlyResolved.length}):`) + for (const entry of newlyResolved) { + console.log(` + ${entry.symbolicName} โ†’ ${entry.resolvedId}`) + } + } + + if (changed.length > 0) { + console.warn(`[refresh-permission-groups] UUID drift detected (${changed.length}):`) + for (const entry of changed) { + console.warn(` ~ ${entry.symbolicName}: ${entry.previousId} โ†’ ${entry.resolvedId}`) + } + } + + if (stillMissing.length > 0) { + console.warn(`[refresh-permission-groups] Still unverified after refresh (${stillMissing.length}):`) + for (const entry of stillMissing) { + console.warn(` ? ${entry.symbolicName} (display name: '${entry.displayName}')`) + } + } + + if (newlyResolved.length === 0 && changed.length === 0 && stillMissing.length === 0) { + console.log('[refresh-permission-groups] No changes; all entries already verified.') + } +} + +async function refreshPermissionGroups(options: RefreshOptions): Promise { + const apiPermissionGroups = await listAccountTokenPermissionGroups(options.accountId, { + token: options.apiToken + }) + + const entries = resolveUpdatedEntries(apiPermissionGroups, { + keepExisting: options.keepExisting + }) + + reportDrift(entries) + + const generatedSource = renderGeneratedFile(entries) + + if (options.dryRun) { + console.log('[refresh-permission-groups] --dry-run; not writing to disk. Would write:') + console.log('--- BEGIN GENERATED FILE ---') + console.log(generatedSource) + console.log('--- END GENERATED FILE ---') + return + } + + await writeFile(options.outputPath, generatedSource, 'utf-8') + console.log(`[refresh-permission-groups] Wrote ${options.outputPath}`) +} + +async function main(): Promise { + const cliFlags = parseCliFlags(process.argv.slice(2)) + const accountId = readRequiredEnv('CLOUDFLARE_ACCOUNT_ID') + const apiToken = readRequiredEnv('CLOUDFLARE_API_TOKEN') + const outputPath = cliFlags.outputOverride + ? resolve(process.cwd(), cliFlags.outputOverride) + : process.env.DEVFLARE_PERMISSION_GROUP_OUTPUT + ? resolve(process.cwd(), process.env.DEVFLARE_PERMISSION_GROUP_OUTPUT) + : getDefaultOutputPath() + + await refreshPermissionGroups({ + accountId, + apiToken, + outputPath, + dryRun: cliFlags.dryRun, + keepExisting: cliFlags.keepExisting + }) +} + +// Only auto-run `main()` when this file is invoked as the CLI entrypoint +// (e.g. `bun run scripts/refresh-permission-groups.ts`). Importing the named +// exports from tests must NOT side-effect into `main()` โ€” otherwise the +// missing CLOUDFLARE_* env vars would throw, set `process.exitCode = 1`, and +// poison the surrounding `bun test` run even though all assertions pass. +const isCliEntry = (() => { + try { + const argv1 = process.argv[1] + if (!argv1) { + return false + } + // Bun exposes `import.meta.path`; resolve both via realpath-ish equality. + return import.meta.path === argv1 || import.meta.url === `file://${argv1}` + } catch { + return false + } +})() + +if (isCliEntry) { + void main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(`[refresh-permission-groups] Failed: ${message}`) + process.exitCode = 1 + }) +} + +// Exported for unit testing without running the CLI entrypoint. +export { renderGeneratedFile, resolveUpdatedEntries } diff --git a/packages/devflare/src/bridge/client.ts b/packages/devflare/src/bridge/client.ts new file mode 100644 index 0000000..63deb19 --- /dev/null +++ b/packages/devflare/src/bridge/client.ts @@ -0,0 +1,794 @@ +// ============================================================================= +// Bridge Client โ€” WebSocket Client for Node.js/Bun +// ============================================================================= +// Connects to the Miniflare gateway worker and provides an RPC interface. +// +// Wire protocol: TransportV2Codec owns the underlying socket. On connect, the +// client sends `hello` and the server replies `welcome` (or simply ignores the +// handshake โ€” connect resolves on socket open either way, and the codec keeps +// the handshake promise live for capability negotiation). RPC, body, stream +// and ws-relay messages are dispatched through the codec. +// ============================================================================= + +import { + type JsonMsg, + type StreamPull, + type WsOpen, + type WsOpened, + type WsClose, + parseJsonMsg, + stringifyJsonMsg, + encodeBinaryFrame, + decodeBinaryFrame, + BinaryKind, + BinaryFlags, + nextWsId, + DEFAULT_BRIDGE_PORT, + DEFAULT_CHUNK_SIZE +} from './v2/wire' +import { + serializeValue, + deserializeValue, + type StreamRef +} from './v2/value-serialization' +import { TransportV2Codec } from './v2/codec' +import type { + WebSocketLike, + WebSocketLikeMessageEvent, + WebSocketLikeCloseEvent +} from './v2/transport' +import type { TransportV2DecodedBinaryFrame } from './v2/frames' +import { bridgeLog } from './log' + +// ----------------------------------------------------------------------------- +// Internal โ€” adapter that exposes a real browser/Node WebSocket as a +// WebSocketLike target the v2 codec can attach to. We retain control over the +// real WebSocket's onopen handler so that BridgeClient.connect() can resolve +// on socket open while the codec runs the handshake in the background. +// ----------------------------------------------------------------------------- + +class BridgeWsAdapter implements WebSocketLike { + onmessage: ((event: WebSocketLikeMessageEvent) => void) | null = null + onclose: ((event: WebSocketLikeCloseEvent) => void) | null = null + onerror: ((event: { error?: unknown }) => void) | null = null + #ws: WebSocket + + constructor(ws: WebSocket) { + this.#ws = ws + } + + send(data: string | Uint8Array): void { + this.#ws.send(data as never) + } + + close(code?: number, reason?: string): void { + this.#ws.close(code, reason) + } +} + +const BRIDGE_CLIENT_CAPABILITIES = ['streams', 'ws-relay', 'http-transfer'] as const +type WebSocketConstructor = new (url: string) => WebSocket +let wsPackageConstructorPromise: Promise | null = null + +async function importWsPackageConstructor(): Promise { + if (!wsPackageConstructorPromise) { + wsPackageConstructorPromise = (async () => { + const dynamicImport = new Function( + 'specifier', + ['return ', 'import', '(specifier)'].join('') + ) as ( + specifier: string + ) => Promise<{ + WebSocket?: unknown + default?: unknown + }> + const wsModule = await dynamicImport('ws') + const defaultExport = wsModule.default as { WebSocket?: unknown } | unknown + const constructor = + wsModule.WebSocket ?? + (typeof defaultExport === 'object' && defaultExport !== null + ? (defaultExport as { WebSocket?: unknown }).WebSocket + : undefined) ?? + defaultExport + + if (typeof constructor !== 'function') { + throw new Error('Could not load a WebSocket client implementation from the ws package') + } + + return constructor as WebSocketConstructor + })() + } + + return wsPackageConstructorPromise +} + +function getRuntimeWebSocketConstructor( + runtimeWebSocket: unknown = globalThis.WebSocket +): WebSocketConstructor | null { + if (typeof runtimeWebSocket === 'function') { + return runtimeWebSocket as WebSocketConstructor + } + + return null +} + +export async function resolveBridgeWebSocketConstructor( + runtimeWebSocket: unknown = globalThis.WebSocket +): Promise { + const constructor = getRuntimeWebSocketConstructor(runtimeWebSocket) + if (constructor) return constructor + + return importWsPackageConstructor() +} + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface BridgeClientOptions { + /** Bridge WebSocket URL (default: ws://localhost:8686) */ + url?: string + /** Auto-reconnect on disconnect */ + autoReconnect?: boolean + /** Reconnect delay in ms */ + reconnectDelay?: number + /** Connection timeout in ms */ + connectTimeout?: number +} + +export interface PendingCall { + resolve: (value: unknown) => void + reject: (error: Error) => void + timeout: ReturnType +} + +export interface ActiveStream { + controller: ReadableStreamDefaultController + buffer: Uint8Array[] + creditRemaining: number + /** Resolver for a pending pull waiting on bytes or end */ + pendingPull: { + resolve: () => void + reject: (error: Error) => void + } | null + /** Stream ended (from server) โ€” signal pull to flush and close */ + ended: boolean + /** Stream was cancelled locally or aborted */ + closed: boolean +} + +export interface ActiveWsProxy { + clientWs: WebSocket + onMessage: (data: Uint8Array | string) => void + onClose: (code?: number, reason?: string) => void +} + +export interface PendingWsOpen { + resolve: () => void + reject: (error: Error) => void +} + +// ----------------------------------------------------------------------------- +// Bridge Client +// ----------------------------------------------------------------------------- + +export class BridgeClient { + private ws: WebSocket | null = null + private codec: TransportV2Codec | null = null + private adapter: BridgeWsAdapter | null = null + private url: string + private autoReconnect: boolean + private reconnectDelay: number + private connectTimeout: number + + private activeStreams = new Map() + private wsProxies = new Map() + private pendingWsOpens = new Map() + private outgoingStreams = new Map() + + private connectPromise: Promise | null = null + private isConnected = false + + constructor(options: BridgeClientOptions = {}) { + this.url = options.url ?? `ws://localhost:${DEFAULT_BRIDGE_PORT}` + this.autoReconnect = options.autoReconnect ?? true + this.reconnectDelay = options.reconnectDelay ?? 1000 + this.connectTimeout = options.connectTimeout ?? 5000 + } + + /** Get the WebSocket URL */ + getUrl(): string { + return this.url + } + + /** Get the HTTP URL for transfer endpoint */ + getHttpUrl(): string { + // Convert ws://... to http://... + return this.url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://') + } + + // --------------------------------------------------------------------------- + // Connection Management + // --------------------------------------------------------------------------- + + /** Connect to the bridge */ + async connect(): Promise { + if (this.isConnected) return + if (this.connectPromise) return this.connectPromise + + const WebSocketCtor = getRuntimeWebSocketConstructor() + const promise = WebSocketCtor + ? this.openConnection(WebSocketCtor) + : this.openConnectionWithPackageFallback() + this.connectPromise = promise + promise.catch(() => { + if (this.connectPromise === promise) { + this.connectPromise = null + } + }) + + return promise + } + + private async openConnectionWithPackageFallback(): Promise { + const WebSocketCtor = await importWsPackageConstructor() + return this.openConnection(WebSocketCtor) + } + + private openConnection(WebSocketCtor: WebSocketConstructor): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Connection timeout: ${this.url}`)) + this.ws?.close() + }, this.connectTimeout) + + try { + this.ws = new WebSocketCtor(this.url) + this.ws.binaryType = 'arraybuffer' + + const adapter = new BridgeWsAdapter(this.ws) + this.adapter = adapter + + this.ws.onopen = () => { + clearTimeout(timeout) + // Attach the v2 codec only once the socket is open so its + // `sendHello()` call writes to a live transport. + this.codec = new TransportV2Codec(adapter, { + capabilities: [...BRIDGE_CLIENT_CAPABILITIES], + onUnknownControl: (data) => this.handleJsonMessage(data), + onUnknownBinary: (frame) => this.handleV2BinaryFrame(frame) + }) + // Fire the handshake but do NOT block connect() on it. The + // gateway-runtime side acks with `welcome`; servers that do + // not implement v2 simply ignore it (the codec dispatches + // unknown text to onUnknownControl, which silently drops). + this.codec.sendHello() + this.codec.handshake.catch(() => { /* surfaced through cleanupPending */ }) + this.isConnected = true + this.connectPromise = null + resolve() + } + + this.ws.onerror = (event) => { + clearTimeout(timeout) + this.connectPromise = null + adapter.onerror?.({ error: (event as unknown as { error?: unknown }).error }) + reject(new Error('WebSocket connection failed')) + } + + this.ws.onclose = (event) => { + adapter.onclose?.({ + code: event?.code ?? 1006, + reason: event?.reason ?? '' + }) + this.handleDisconnect() + } + + this.ws.onmessage = (event) => { + adapter.onmessage?.({ data: event.data }) + } + } catch (error) { + clearTimeout(timeout) + this.connectPromise = null + reject(error) + } + }) + } + + /** Disconnect from the bridge and tear down all pending state */ + disconnect(): void { + this.autoReconnect = false + // Closing the codec rejects any pending RPC calls registered there + // with the codec's own "v2 transport closed" error; cleanupPending() + // then layers the BridgeClient-level streams/ws teardown. + this.codec?.close() + this.codec = null + this.adapter = null + this.ws?.close() + this.ws = null + this.isConnected = false + this.cleanupPending(new Error('Bridge disconnected')) + } + + /** Alias for disconnect() */ + close(): void { + this.disconnect() + } + + /** Check if connected */ + get connected(): boolean { + return this.isConnected + } + + private handleDisconnect(): void { + this.isConnected = false + this.codec?.close() + this.codec = null + this.adapter = null + this.ws = null + + this.cleanupPending(new Error('Bridge disconnected')) + + // Auto-reconnect + if (this.autoReconnect) { + setTimeout(() => { + this.connect().catch((error) => { + bridgeLog.warn('auto-reconnect attempt failed', error) + }) + }, this.reconnectDelay) + } + } + + /** Reject/close all pending streams and ws proxies (codec owns RPC pending). */ + private cleanupPending(error: Error): void { + // Reject pending ws.opened waits + for (const pending of this.pendingWsOpens.values()) { + pending.reject(error) + } + this.pendingWsOpens.clear() + + // Error out active incoming streams and reject any pending pull + for (const stream of this.activeStreams.values()) { + stream.closed = true + if (stream.pendingPull) { + stream.pendingPull.reject(error) + stream.pendingPull = null + } + try { + stream.controller.error(error) + } catch { + // controller may already be closed + } + } + this.activeStreams.clear() + + // Notify active ws proxies of close + for (const proxy of this.wsProxies.values()) { + try { + proxy.onClose(1006, error.message) + } catch { + // swallow handler errors during cleanup + } + } + this.wsProxies.clear() + + // Drop outgoing stream refs + this.outgoingStreams.clear() + } + + // --------------------------------------------------------------------------- + // RPC Interface + // --------------------------------------------------------------------------- + + /** Call an RPC method */ + async call(method: string, params: unknown[], timeoutMs = 30000): Promise { + await this.ensureConnected() + + const codec = this.codec + if (!codec) { + throw new Error('Bridge disconnected') + } + + // Serialize params (may produce streams) + const { value: serializedParams, streams } = await serializeValue(params) + + // Register outgoing streams + for (const streamRef of streams) { + this.outgoingStreams.set(streamRef.sid, streamRef) + } + + const callPromise = codec.call(method, serializedParams as unknown[]) + + let timeoutHandle: ReturnType | null = null + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`RPC timeout: ${method}`)) + }, timeoutMs) + }) + + try { + const rawResult = await Promise.race([callPromise, timeoutPromise]) + return deserializeValue(rawResult, (sid) => this.createReadableStream(sid)) + } catch (error) { + // Re-wrap codec's "v2 transport closed" error so callers continue + // to see the BridgeClient-level disconnect message they expect. + if (!this.isConnected && error instanceof Error + && /v2 transport closed|transport/.test(error.message)) { + throw new Error('Bridge disconnected') + } + throw error + } finally { + if (timeoutHandle !== null) clearTimeout(timeoutHandle) + } + } + + // --------------------------------------------------------------------------- + // WebSocket Proxy + // --------------------------------------------------------------------------- + + /** Create a proxied WebSocket to a Durable Object */ + async createWsProxy( + binding: string, + id: string, + url: string, + headers?: [string, string][] + ): Promise<{ + wid: number + send: (data: Uint8Array | string) => void + close: (code?: number, reason?: string) => void + onMessage: (handler: (data: Uint8Array | string) => void) => void + onClose: (handler: (code?: number, reason?: string) => void) => void + }> { + await this.ensureConnected() + + const wid = nextWsId() + + const proxy: ActiveWsProxy = { + clientWs: null as any, // Not a real WS, we handle it + onMessage: () => { }, + onClose: () => { } + } + this.wsProxies.set(wid, proxy) + + // Register the pending open BEFORE sending so we can't miss ws.opened + const openedPromise = new Promise((resolve, reject) => { + this.pendingWsOpens.set(wid, { resolve, reject }) + }) + + // Send open request + const msg: WsOpen = { + t: 'ws.open', + wid, + target: { binding, id, url, headers } + } + this.send(msg) + + // Await confirmation from the bridge before returning the proxy + try { + await openedPromise + } catch (error) { + this.wsProxies.delete(wid) + throw error + } + + return { + wid, + send: (data) => { + const payload = typeof data === 'string' + ? new TextEncoder().encode(data) + : data + const flags = typeof data === 'string' ? BinaryFlags.TEXT : 0 + const frame = encodeBinaryFrame(BinaryKind.WsData, wid, 0, flags, payload) + this.sendBinary(frame) + }, + close: (code, reason) => { + const closeMsg: WsClose = { t: 'ws.close', wid, code, reason } + this.send(closeMsg) + this.wsProxies.delete(wid) + }, + onMessage: (handler) => { + proxy.onMessage = handler + }, + onClose: (handler) => { + proxy.onClose = handler + } + } + } + + // --------------------------------------------------------------------------- + // Stream Interface + // --------------------------------------------------------------------------- + + /** Create a readable stream that pulls from the bridge */ + createReadableStream(sid: number): ReadableStream { + return new ReadableStream({ + start: (controller) => { + this.activeStreams.set(sid, { + controller, + buffer: [], + creditRemaining: 0, + pendingPull: null, + ended: false, + closed: false + }) + }, + pull: async (controller) => { + const stream = this.activeStreams.get(sid) + if (!stream || stream.closed) return + + // Flush any buffered chunks first + if (stream.buffer.length > 0) { + const chunk = stream.buffer.shift()! + controller.enqueue(chunk) + return + } + + // If the stream has ended and buffer is empty, close it + if (stream.ended) { + controller.close() + this.activeStreams.delete(sid) + return + } + + // Request more data from the bridge + const pullMsg: StreamPull = { + t: 'stream.pull', + sid, + creditBytes: DEFAULT_CHUNK_SIZE * 4 + } + this.send(pullMsg) + + // Await a signal that bytes arrived, the stream ended, or it was aborted + await new Promise((resolve, reject) => { + stream.pendingPull = { resolve, reject } + }) + stream.pendingPull = null + + if (stream.closed) return + + if (stream.buffer.length > 0) { + const chunk = stream.buffer.shift()! + controller.enqueue(chunk) + return + } + + if (stream.ended) { + controller.close() + this.activeStreams.delete(sid) + } + }, + cancel: () => { + const stream = this.activeStreams.get(sid) + if (stream) { + stream.closed = true + if (stream.pendingPull) { + stream.pendingPull.reject(new Error('Stream cancelled')) + stream.pendingPull = null + } + } + this.activeStreams.delete(sid) + } + }) + } + + // --------------------------------------------------------------------------- + // Message Handling โ€” invoked by TransportV2Codec via onUnknownControl / + // onUnknownBinary hooks. The codec handles `hello`/`welcome`, `body.*` and + // the `rpc.*` envelope itself; everything below is the legacy stream/ws + // vocabulary that v2 keeps bit-compatible for in-flight wire shapes. + // --------------------------------------------------------------------------- + + private handleJsonMessage(data: string): void { + try { + const msg = parseJsonMsg(data) + + switch (msg.t) { + case 'event': + this.handleEvent(msg) + break + case 'stream.pull': + this.handleStreamPull(msg) + break + case 'stream.end': + this.handleStreamEnd(msg) + break + case 'stream.abort': + this.handleStreamAbort(msg) + break + case 'ws.opened': + this.handleWsOpened(msg) + break + case 'ws.close': + this.handleWsClose(msg) + break + } + } catch (error) { + console.error('[devflare bridge client] parse error:', data, error) + } + } + + /** + * Receive a v2 binary frame. Frame format is byte-identical to the legacy + * v1 frames for kinds 1 (StreamChunk) and 2 (WsData); v2 owns kind 3 + * (BodyChunk) which the codec handles internally before reaching here. + */ + private handleV2BinaryFrame(frame: TransportV2DecodedBinaryFrame): void { + switch (frame.kind) { + case BinaryKind.StreamChunk: + this.handleStreamChunk(frame) + break + case BinaryKind.WsData: + this.handleWsData(frame) + break + } + } + + private handleEvent(_msg: { topic: string; data: unknown }): void { + // TODO: Emit event to subscribers when event system is implemented + } + + private handleStreamPull(msg: StreamPull): void { + const streamRef = this.outgoingStreams.get(msg.sid) + if (!streamRef) return + + // Read from stream and send chunks + this.pumpStream(streamRef, msg.creditBytes) + } + + private async pumpStream(streamRef: StreamRef, creditBytes: number): Promise { + const reader = streamRef.stream.getReader() + let sent = 0 + let seq = 0 + + try { + while (sent < creditBytes) { + const { done, value } = await reader.read() + + if (done) { + // Send end message + this.send({ t: 'stream.end', sid: streamRef.sid }) + this.outgoingStreams.delete(streamRef.sid) + break + } + + if (value) { + // Send chunk + const frame = encodeBinaryFrame( + BinaryKind.StreamChunk, + streamRef.sid, + seq++, + 0, + value + ) + this.sendBinary(frame) + sent += value.byteLength + } + } + } catch (error) { + this.send({ + t: 'stream.abort', + sid: streamRef.sid, + error: String(error) + }) + this.outgoingStreams.delete(streamRef.sid) + } finally { + reader.releaseLock() + } + } + + private handleStreamChunk(decoded: { id: number; payload: Uint8Array }): void { + const stream = this.activeStreams.get(decoded.id) + if (!stream || stream.closed) return + + stream.buffer.push(decoded.payload) + if (stream.pendingPull) { + const pending = stream.pendingPull + stream.pendingPull = null + pending.resolve() + } + } + + private handleStreamEnd(msg: { sid: number }): void { + const stream = this.activeStreams.get(msg.sid) + if (!stream) return + + stream.ended = true + if (stream.pendingPull) { + const pending = stream.pendingPull + stream.pendingPull = null + pending.resolve() + } + } + + private handleStreamAbort(msg: { sid: number; error?: string }): void { + const stream = this.activeStreams.get(msg.sid) + if (!stream) return + + const err = new Error(msg.error ?? 'Stream aborted') + stream.closed = true + if (stream.pendingPull) { + const pending = stream.pendingPull + stream.pendingPull = null + pending.reject(err) + } + try { + stream.controller.error(err) + } catch { + // already closed + } + this.activeStreams.delete(msg.sid) + } + + private handleWsData(decoded: { id: number; flags: number; payload: Uint8Array }): void { + const proxy = this.wsProxies.get(decoded.id) + if (!proxy) return + + const isText = (decoded.flags & BinaryFlags.TEXT) !== 0 + const data = isText + ? new TextDecoder().decode(decoded.payload) + : decoded.payload + + proxy.onMessage(data) + } + + private handleWsClose(msg: WsClose): void { + const proxy = this.wsProxies.get(msg.wid) + if (!proxy) return + + proxy.onClose(msg.code, msg.reason) + this.wsProxies.delete(msg.wid) + } + + private handleWsOpened(msg: WsOpened): void { + const pending = this.pendingWsOpens.get(msg.wid) + if (!pending) return + this.pendingWsOpens.delete(msg.wid) + pending.resolve() + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private async ensureConnected(): Promise { + if (!this.isConnected) { + await this.connect() + } + } + + private send(msg: JsonMsg): void { + const codec = this.codec + if (!codec || !this.isConnected) { + throw new Error('Not connected to bridge') + } + codec.sendText(stringifyJsonMsg(msg)) + } + + private sendBinary(frame: Uint8Array): void { + const codec = this.codec + if (!codec || !this.isConnected) { + throw new Error('Not connected to bridge') + } + codec.sendBinary(frame) + } +} + +// ----------------------------------------------------------------------------- +// Singleton Instance +// ----------------------------------------------------------------------------- + +let defaultClient: BridgeClient | null = null + +/** Get or create the default bridge client */ +export function getClient(options?: BridgeClientOptions): BridgeClient { + if (!defaultClient) { + defaultClient = new BridgeClient(options) + } + return defaultClient +} + +/** Reset the default client (for testing) */ +export function resetClient(): void { + defaultClient?.disconnect() + defaultClient = null +} diff --git a/packages/devflare/src/bridge/gateway-runtime.ts b/packages/devflare/src/bridge/gateway-runtime.ts new file mode 100644 index 0000000..45218ec --- /dev/null +++ b/packages/devflare/src/bridge/gateway-runtime.ts @@ -0,0 +1,534 @@ +// ============================================================================= +// Bridge Gateway Runtime โ€” Shared Inline Script Source +// ============================================================================= +// The gateway worker runs inside the Miniflare sandbox and cannot import from +// the host package at runtime. To avoid keeping two diverging copies of the +// dispatch/serialization logic, the shared pieces are defined here as a +// stringified JS template that is concatenated into both generated gateway +// scripts (see `miniflare.ts` and `dev-server/gateway-script.ts`). +// +// The canonical TypeScript transport lives in `./server.ts`. This file only +// contains the subset of behavior that both inline gateway variants need to +// agree on (RPC method vocabulary, error envelope, serialization helpers). +// ============================================================================= + +/** + * Shared gateway helpers (base64, R2 serializers, email) and the RPC method + * dispatcher. Designed to be embedded verbatim at the top level of a worker + * module. All symbols are declared with `function`/`const` so they are + * hoisted in both embedding sites. + */ +export const GATEWAY_RUNTIME_JS = ` +const RAW_EMAIL = 'EmailMessage::raw' + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary) +} + +function base64ToArrayBuffer(base64) { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +function serializeR2Object(obj) { + if (!obj) return null + return { + __type: 'R2Object', + key: obj.key, + version: obj.version, + size: obj.size, + etag: obj.etag, + httpEtag: obj.httpEtag, + checksums: obj.checksums, + uploaded: obj.uploaded?.toISOString(), + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + range: obj.range, + storageClass: obj.storageClass + } +} + +function serializeR2ObjectBody(obj, bodyData) { + if (!obj) return null + return { + __type: 'R2ObjectBody', + key: obj.key, + version: obj.version, + size: obj.size, + etag: obj.etag, + httpEtag: obj.httpEtag, + checksums: obj.checksums, + uploaded: obj.uploaded?.toISOString(), + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + range: obj.range, + storageClass: obj.storageClass, + bodyData + } +} + +function serializeR2Objects(result) { + if (!result) return null + return { + objects: result.objects.map(serializeR2Object), + truncated: result.truncated, + cursor: result.cursor, + delimitedPrefixes: result.delimitedPrefixes + } +} + +async function serializeResponse(response) { + let body = null + if (response.body) { + const bytes = await response.arrayBuffer() + if (bytes.byteLength > 0) { + body = { type: 'bytes', data: arrayBufferToBase64(bytes) } + } + } + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + body + } +} + +function deserializeRequest(serializedReq) { + return new Request(serializedReq.url, { + method: serializedReq.method, + headers: serializedReq.headers, + body: serializedReq.body?.type === 'bytes' + ? base64ToArrayBuffer(serializedReq.body.data) + : undefined, + redirect: serializedReq.redirect + }) +} + +function createEmailMessageRaw(raw) { + if (typeof raw === 'string' || raw instanceof ReadableStream) { + return raw + } + if (raw instanceof ArrayBuffer || raw instanceof Uint8Array) { + return new Response(raw).body + } + throw new Error('Unsupported EmailMessage raw payload') +} + +function isDurableObjectNamespace(binding) { + return !!binding + && typeof binding.idFromName === 'function' + && typeof binding.idFromString === 'function' + && typeof binding.newUniqueId === 'function' +} + +/** + * Execute an RPC method against the gateway's bindings. + * + * Method format: "binding.operation". Operations must be namespaced by + * binding kind (e.g. "kv.get", "r2.head", "d1.stmt.first", "do.fetch", + * "service.fetch", "queue.send", "email.send", "ai.run"). Bare verbs and the legacy + * "stmt.*" / "stub.*" sub-prefixes were removed in B3-final and now throw. + * Method vocabulary must stay in sync with the canonical server in + * src/bridge/server.ts. + */ +async function executeRpcMethod(method, params, env, _ctx) { + const parts = method.split('.') + if (parts.length < 2) throw new Error('Invalid method format: ' + method) + + const bindingName = parts[0] + const operation = parts.slice(1).join('.') + const binding = env[bindingName] + + if (!binding) throw new Error('Binding not found: ' + bindingName) + + const isNamespaced = + operation.indexOf('kv.') === 0 || + operation.indexOf('r2.') === 0 || + operation.indexOf('d1.') === 0 || + operation.indexOf('do.') === 0 || + operation.indexOf('service.') === 0 || + operation.indexOf('queue.') === 0 || + operation.indexOf('email.') === 0 || + operation.indexOf('ai.') === 0 || + operation.indexOf('workflow.') === 0 || + operation.indexOf('var.') === 0 + if (!isNamespaced) { + throw new Error(createUnsupportedBridgeOperationErrorMessage(bindingName, operation)) + } + + // KV + if (operation === 'kv.get') return binding.get(params[0], params[1]) + if (operation === 'kv.put') return binding.put(params[0], params[1], params[2]) + if (operation === 'kv.delete') return binding.delete(params[0]) + if (operation === 'kv.list') return binding.list(params[0]) + if (operation === 'kv.getWithMetadata') return binding.getWithMetadata(params[0], params[1]) + + // DO get (returns DOStub reference) + if (operation === 'do.get') { + return { __type: 'DOStub', binding: bindingName, id: params[0] } + } + + // R2 + if (operation === 'r2.head') return serializeR2Object(await binding.head(params[0])) + if (operation === 'r2.get') { + const obj = await binding.get(params[0], params[1]) + if (!obj) return null + const body = await obj.arrayBuffer() + return serializeR2ObjectBody(obj, arrayBufferToBase64(body)) + } + if (operation === 'r2.put') { + let value = params[1] + if (value && typeof value === 'object') { + if (value.__type === 'ArrayBuffer' || value.__type === 'Uint8Array') { + value = base64ToArrayBuffer(value.data) + } + } + return serializeR2Object(await binding.put(params[0], value, params[2])) + } + if (operation === 'r2.delete') return binding.delete(params[0]) + if (operation === 'r2.list') return serializeR2Objects(await binding.list(params[0])) + + // D1 + if (operation === 'd1.exec') return binding.exec(params[0]) + if (operation === 'd1.batch') { + const statements = params[0].map((s) => binding.prepare(s.sql).bind(...(s.bindings || []))) + return binding.batch(statements) + } + if (operation.indexOf('d1.stmt.') === 0) { + const mode = operation.split('.')[2] + const [sql, ...rest] = params + let bindings = rest + let extraParam + if (mode === 'first' || mode === 'raw') { + extraParam = rest[rest.length - 1] + bindings = rest.slice(0, -1) + } + let stmt = binding.prepare(sql) + if (bindings.length > 0) stmt = stmt.bind(...bindings) + if (mode === 'first') { + if (typeof extraParam === 'string' && extraParam.length > 0) return stmt.first(extraParam) + return stmt.first() + } + if (mode === 'all') return stmt.all() + if (mode === 'run') return stmt.run() + if (mode === 'raw') return stmt.raw(extraParam) + throw new Error('Unknown stmt mode: ' + mode) + } + + // Durable Objects + if (operation === 'do.idFromName') { + const id = binding.idFromName(params[0]) + return { __type: 'DOId', hex: id.toString() } + } + if (operation === 'do.idFromString') { + const id = binding.idFromString(params[0]) + return { __type: 'DOId', hex: id.toString() } + } + if (operation === 'do.newUniqueId') { + const id = binding.newUniqueId(params[0]) + return { __type: 'DOId', hex: id.toString() } + } + if (operation === 'do.fetch') { + const [, serializedId, serializedReq] = params + const id = binding.idFromString(serializedId.hex) + const stub = binding.get(id) + const response = await stub.fetch(new Request(serializedReq.url, { + method: serializedReq.method, + headers: serializedReq.headers, + body: serializedReq.body?.type === 'bytes' + ? base64ToArrayBuffer(serializedReq.body.data) + : undefined + })) + return serializeResponse(response) + } + if (operation === 'do.rpc') { + const [, serializedId, methodName, args] = params + const id = binding.idFromString(serializedId.hex) + const stub = binding.get(id) + const response = await stub.fetch(new Request('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: methodName, params: args }) + })) + const result = await response.json() + if (!result.ok) throw new Error(result.error?.message || 'RPC failed') + return result.result + } + + // Service Bindings + if (operation === 'service.fetch') { + if (!binding || typeof binding.fetch !== 'function') { + throw new Error('Service binding ' + bindingName + ' does not support fetch()') + } + const response = await binding.fetch(deserializeRequest(params[0])) + return serializeResponse(response) + } + if (operation === 'service.rpc') { + const methodName = params[0] + if (typeof methodName !== 'string') { + throw new Error('Service binding ' + bindingName + ' RPC method name must be a string') + } + const args = Array.isArray(params[1]) ? params[1] : [] + const method = binding && binding[methodName] + if (typeof method !== 'function') { + throw new Error('Service binding ' + bindingName + ' does not support ' + methodName + '()') + } + return method.apply(binding, args) + } + + // Queues + if (operation === 'queue.send') return binding.send(params[0], params[1]) + if (operation === 'queue.sendBatch') return binding.sendBatch(params[0], params[1]) + + // Send Email + if (operation === 'email.send') { + if (binding && typeof binding.send === 'function') { + const message = params[0] + if (message && typeof message === 'object' && 'from' in message && 'to' in message && 'raw' in message) { + return binding.send({ + from: message.from, + to: message.to, + [RAW_EMAIL]: createEmailMessageRaw(message.raw) + }) + } + return binding.send(message) + } + return { ok: true, simulated: true } + } + + // Workflows + if (operation === 'workflow.create') { + return serializeWorkflowInstance(await binding.create(params[0])) + } + if (operation === 'workflow.get') { + return serializeWorkflowInstance(await binding.get(params[0])) + } + if (operation === 'workflow.status') { + return (await binding.get(params[0])).status() + } + if (operation === 'workflow.pause') { + return (await binding.get(params[0])).pause() + } + if (operation === 'workflow.resume') { + return (await binding.get(params[0])).resume() + } + if (operation === 'workflow.terminate') { + return (await binding.get(params[0])).terminate() + } + if (operation === 'workflow.restart') { + return (await binding.get(params[0])).restart() + } + if (operation === 'workflow.sendEvent') { + return (await binding.get(params[0])).sendEvent(params[1]) + } + + // AI / generic run() + if (operation === 'ai.run') { + if (typeof binding.run !== 'function') { + throw new Error('Binding ' + bindingName + ' does not support run(): ' + method) + } + return binding.run(params[0], params[1]) + } + + throw new Error('Unknown operation: ' + method) +} + +function createUnsupportedBridgeOperationErrorMessage(bindingName, operation) { + const base = "[devflare][bridge] Unsupported bridge operation '" + operation + "' for binding '" + bindingName + "'." + if (operation === 'fetch') { + return base + ' Devflare could not dispatch fetch() for this binding through the local bridge. ' + + 'Expected Cloudflare API: env.' + bindingName + '.fetch(request). ' + + 'If this came from SvelteKit platform.env, make sure the binding is declared as a service binding; ' + + 'this is a Devflare local bridge issue when service bindings fall back to a bare fetch operation.' + } + if (operation === 'toString') { + return base + ' A platform.env value was coerced to a string through the bridge. ' + + 'For SvelteKit local dev, declared vars should be plain string values and missing env names should read as undefined.' + } + return base + ' Bare verbs and the legacy stmt.*/stub.* sub-prefixes are not supported; ' + + 'use the namespaced form (e.g. kv.get, r2.put, d1.stmt.first, do.fetch, service.fetch).' +} + +function serializeWorkflowInstance(instance) { + return { + __type: 'WorkflowInstance', + id: instance.id + } +} + +// --------------------------------------------------------------------------- +// WebSocket bridge (shared with src/bridge/server.ts in shape) +// --------------------------------------------------------------------------- +// NOTE: wsProxies is intentionally created per handleBridgeWebSocket call so +// state never leaks across connections or across gateway-script regenerations. + +async function handleBridgeRpcCall(msg, ws, env, ctx) { + try { + const result = await executeRpcMethod(msg.method, msg.params, env, ctx) + ws.send(JSON.stringify({ t: 'rpc.ok', id: msg.id, result })) + } catch (error) { + ws.send(JSON.stringify({ + t: 'rpc.err', + id: msg.id, + error: { + code: error?.code || 'INTERNAL_ERROR', + message: error?.message || String(error) + } + })) + } +} + +async function handleBridgeWsOpen(msg, ws, env, wsProxies) { + try { + const binding = env[msg.target.binding] + const id = binding.idFromString(msg.target.id) + const stub = binding.get(id) + + const headers = new Headers(msg.target.headers || []) + headers.set('Upgrade', 'websocket') + + const response = await stub.fetch(new Request(msg.target.url, { method: 'GET', headers })) + const doWs = response.webSocket + + if (!doWs) { + ws.send(JSON.stringify({ + t: 'rpc.err', + id: 'ws_' + msg.wid, + error: { code: 'WS_FAILED', message: 'No WebSocket returned' } + })) + return + } + + doWs.accept() + wsProxies.set(msg.wid, { doWs }) + + doWs.addEventListener('message', (event) => { + const isText = typeof event.data === 'string' + const data = isText ? event.data : arrayBufferToBase64(event.data) + ws.send(JSON.stringify({ t: 'ws.data', wid: msg.wid, data, isText })) + }) + + doWs.addEventListener('close', (event) => { + ws.send(JSON.stringify({ t: 'ws.close', wid: msg.wid, code: event.code, reason: event.reason })) + wsProxies.delete(msg.wid) + }) + + ws.send(JSON.stringify({ t: 'ws.opened', wid: msg.wid })) + } catch (error) { + ws.send(JSON.stringify({ + t: 'rpc.err', + id: 'ws_' + msg.wid, + error: { code: 'WS_FAILED', message: error.message } + })) + } +} + +function handleBridgeWsClose(msg, wsProxies) { + const proxy = wsProxies.get(msg.wid) + if (proxy) { + proxy.doWs.close(msg.code, msg.reason) + wsProxies.delete(msg.wid) + } +} + +async function handleBridgeJsonMessage(data, ws, env, ctx, wsProxies) { + const msg = JSON.parse(data) + switch (msg.t) { + case 'hello': + // v2 handshake โ€” acknowledge with welcome echoing the negotiated + // capability intersection. Capabilities advertised by the gateway + // are kept in sync with src/bridge/client.ts (BRIDGE_CLIENT_CAPABILITIES). + ws.send(JSON.stringify({ + t: 'welcome', + protocolVersion: 2, + capabilities: ['streams', 'ws-relay', 'http-transfer'] + .filter((c) => Array.isArray(msg.capabilities) && msg.capabilities.includes(c)) + .sort() + })) + break + case 'rpc.call': + await handleBridgeRpcCall(msg, ws, env, ctx) + break + case 'ws.open': + await handleBridgeWsOpen(msg, ws, env, wsProxies) + break + case 'ws.close': + handleBridgeWsClose(msg, wsProxies) + break + } +} + +function handleBridgeWebSocket(request, env, ctx) { + const { 0: client, 1: server } = new WebSocketPair() + server.accept() + + // Per-connection state: recreated for every bridge client so reloads and + // concurrent clients never share WS proxy entries. + const wsProxies = new Map() + + server.addEventListener('message', async (event) => { + try { + if (typeof event.data === 'string') { + await handleBridgeJsonMessage(event.data, server, env, ctx, wsProxies) + } + } catch (error) { + console.error('[Gateway] Error:', error) + } + }) + + server.addEventListener('close', () => { + for (const proxy of wsProxies.values()) { + // Best-effort cleanup: the DO-side WS may already be closed or in an + // invalid state; any throw here would abort sibling closes. Surface + // the swallowed error when DEVFLARE_DEBUG_BRIDGE is enabled. + try { proxy.doWs.close() } catch (error) { + if (globalThis.DEVFLARE_DEBUG_BRIDGE) { + console.warn('[devflare:bridge] proxy.doWs.close() failed', error) + } + } + } + wsProxies.clear() + }) + + return new Response(null, { status: 101, webSocket: client }) +} + +// --------------------------------------------------------------------------- +// HTTP transfer for R2 bodies (shared with src/bridge/server.ts in shape) +// --------------------------------------------------------------------------- + +async function handleHttpTransfer(request, env, url) { + const transferIdEncoded = url.pathname.split('/').pop() + const transferId = decodeURIComponent(transferIdEncoded || '') + const [binding, ...keyParts] = transferId.split(':') + const key = keyParts.join(':') + const bucket = env[binding] + + if (!bucket) return new Response('Bucket not found: ' + binding, { status: 404 }) + + if (request.method === 'PUT' || request.method === 'POST') { + const result = await bucket.put(key, request.body) + return new Response(JSON.stringify(serializeR2Object(result)), { + headers: { 'Content-Type': 'application/json' } + }) + } + + if (request.method === 'GET') { + const object = await bucket.get(key) + if (!object) return new Response('Not found', { status: 404 }) + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', + 'Content-Length': String(object.size) + } + }) + } + + return new Response('Method not allowed', { status: 405 }) +} +` diff --git a/packages/devflare/src/bridge/index.ts b/packages/devflare/src/bridge/index.ts new file mode 100644 index 0000000..d188d7b --- /dev/null +++ b/packages/devflare/src/bridge/index.ts @@ -0,0 +1,83 @@ +// ============================================================================= +// Bridge Module โ€” Exports +// ============================================================================= + +// Protocol & Message Types +export { + type JsonMsg, + type RpcCall, + type RpcOk, + type RpcErr, + type StreamPull, + type StreamOpen, + type StreamEnd, + type StreamAbort, + type WsOpen, + type WsOpened, + type WsClose, + type EventMsg, + type HttpTransfer, + type DecodedBinaryFrame, + BinaryKind, + BinaryFlags, + BINARY_HEADER_SIZE, + encodeBinaryFrame, + decodeBinaryFrame, + isFin, + isText, + parseJsonMsg, + stringifyJsonMsg, + nextRpcId, + nextStreamId, + nextWsId, + resetIdCounters, + DEFAULT_CHUNK_SIZE, + HTTP_TRANSFER_THRESHOLD, + DEFAULT_BRIDGE_PORT, + DEFAULT_HTTP_PORT +} from './v2/wire' + +// Serialization +export { + type SerializedRequest, + type SerializedResponse, + type BodyRef, + serializeRequest, + deserializeRequest, + serializeResponse, + deserializeResponse, + serializeValue, + deserializeValue, + serializeDOId +} from './v2/value-serialization' + +// Client +export { + type BridgeClientOptions, + type PendingCall, + BridgeClient, + getClient +} from './client' + +// Proxy +export { + type EnvProxyOptions, + type BindingHints, + createEnvProxy, + bridgeEnv, + initEnv, + setBindingHints +} from './proxy' + +// Miniflare Orchestration +export { + startMiniflare, + startMiniflareFromConfig, + getMiniflare, + stopMiniflare, + type MiniflareInstance, + type MiniflareOptions +} from './miniflare' + +// Gateway Worker (Server-side) +export { default as gateway } from './server' diff --git a/packages/devflare/src/bridge/log.ts b/packages/devflare/src/bridge/log.ts new file mode 100644 index 0000000..55a4c5e --- /dev/null +++ b/packages/devflare/src/bridge/log.ts @@ -0,0 +1,28 @@ +// ============================================================================= +// Bridge โ€” Debug logging +// ============================================================================= +// Internal helper for non-fatal bridge errors that were previously dropped on +// the floor via silent `catch {}`. Output is gated on the `DEVFLARE_DEBUG_BRIDGE` +// environment variable so production noise stays at zero by default. +// ============================================================================= + +const isDebugEnabled = (): boolean => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const env = (globalThis as any).process?.env + return Boolean(env?.DEVFLARE_DEBUG_BRIDGE) + } catch { + return false + } +} + +export const bridgeLog = { + warn(message: string, error?: unknown): void { + if (!isDebugEnabled()) return + console.warn(`[devflare:bridge] ${message}`, error) + }, + debug(message: string, error?: unknown): void { + if (!isDebugEnabled()) return + console.debug(`[devflare:bridge] ${message}`, error) + } +} diff --git a/packages/devflare/src/bridge/miniflare.ts b/packages/devflare/src/bridge/miniflare.ts new file mode 100644 index 0000000..9757d74 --- /dev/null +++ b/packages/devflare/src/bridge/miniflare.ts @@ -0,0 +1,941 @@ +// ============================================================================= +// Miniflare Orchestration โ€” Programmatic Miniflare Management +// ============================================================================= +// Spawns and manages Miniflare instances with all binding types +// ============================================================================= + +import type { Miniflare as MiniflareType } from 'miniflare' +import { + type DevflareConfig, + getLocalD1DatabaseIdentifier, + getLocalKVNamespaceIdentifier, + normalizeDOBinding, + normalizeArtifactsBinding, + normalizeDispatchNamespaceBinding, + normalizeImagesBinding, + normalizeMediaBinding, + normalizeMtlsCertificateBinding, + normalizePipelineBinding, + normalizeSecretsStoreBinding, + normalizeWorkflowBinding, + resolveConfigEnvVars +} from '../config' +import { applyLocalDevVarsToConfig } from '../config/local-dev-vars' +import { + buildLocalSecretNodeBindings, + buildLocalSecretWrappedBindingConfig, + type LocalSecretWrappedBindingConfig +} from '../secrets/local-secrets' +import { buildLocalBindingShimServiceConfig } from '../shims/local-media-bindings' +import { createMiniflareLog } from '../dev-server/miniflare-log' +import { GATEWAY_RUNTIME_JS } from './gateway-runtime' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface MiniflareInstance { + /** Ready promise - resolves when Miniflare is ready */ + ready: Promise + /** Dispose the Miniflare instance */ + dispose(): Promise + /** Get bindings directly from Miniflare */ + getBindings(): Promise> + /** Get a specific KV namespace by binding name */ + getKVNamespace: MiniflareType['getKVNamespace'] + /** Get a specific R2 bucket by binding name */ + getR2Bucket: MiniflareType['getR2Bucket'] + /** Get a specific D1 database by binding name */ + getD1Database: MiniflareType['getD1Database'] + /** Get a Durable Object namespace by binding name */ + getDurableObjectNamespace: MiniflareType['getDurableObjectNamespace'] + /** Dispatch a fetch request to a worker */ + dispatchFetch: MiniflareType['dispatchFetch'] + /** The underlying Miniflare instance */ + _mf: MiniflareType +} + +export function isIgnorableMiniflareDisposeError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const details = error as Error & { code?: unknown; syscall?: unknown } + return details.code === 'EBADF' && details.syscall === 'kill' +} + +export interface MiniflareOptions { + /** Devflare config to derive bindings from */ + config?: DevflareConfig + /** Port for HTTP server (default: 8787) */ + port?: number + /** Persist data to disk */ + persist?: boolean | string + /** Path to persist data (if persist is true) */ + persistPath?: string + /** Enable verbose logging */ + verbose?: boolean + /** Durable Object classes to register */ + durableObjects?: Record + /** KV namespaces to create - can be array of names or Record */ + kvNamespaces?: string[] | Record + /** R2 buckets to create - can be array of names or Record */ + r2Buckets?: string[] | Record + /** D1 databases to create - can be array of names or Record */ + d1Databases?: string[] | Record + /** Queue bindings */ + queues?: string[] + /** Rate Limiting bindings */ + rateLimits?: Record + /** Version Metadata binding name */ + versionMetadata?: string + /** Worker Loader bindings */ + workerLoaders?: Record> + /** mTLS Certificate bindings */ + mtlsCertificates?: Record + /** Dispatch Namespace bindings */ + dispatchNamespaces?: Record + /** Workflow bindings */ + workflows?: Record< + string, + { + name: string + className: string + scriptName?: string + stepLimit?: number + } + > + /** Pipeline bindings */ + pipelines?: Record + /** Images binding */ + images?: { binding: string } + /** Media Transformations binding */ + media?: { binding: string } + /** Artifacts bindings */ + artifacts?: Record + /** Secrets Store bindings */ + secretsStore?: Record + /** Send Email bindings */ + sendEmail?: Record< + string, + { + destinationAddress?: string + allowedDestinationAddresses?: string[] + allowedSenderAddresses?: string[] + } + > + /** Environment variables */ + bindings?: Record + /** Service bindings */ + serviceBindings?: Record + /** Wrapped bindings to expose object-shaped local binding shims */ + wrappedBindings?: LocalSecretWrappedBindingConfig['wrappedBindings'] + /** Additional module workers needed by wrapped bindings */ + auxiliaryWorkers?: LocalSecretWrappedBindingConfig['workers'] + /** Node-side binding shims merged into `getBindings()` results */ + nodeBindingOverrides?: Record + /** Project root used to load `.dev.vars`/`.env*` for config-based Miniflare */ + cwd?: string + /** Config file path used as the anchor for `.dev.vars`/`.env*` */ + configPath?: string + /** Cloudflare environment name used for local dev var file selection */ + environment?: string + /** Compatibility date */ + compatibilityDate?: string + /** Compatibility flags */ + compatibilityFlags?: string[] +} + +interface MiniflareSendEmailConfig { + send_email: Array<{ + name: string + destination_address?: string + allowed_destination_addresses?: string[] + allowed_sender_addresses?: string[] + }> +} + +type MiniflareRuntime = Awaited> +type MfOptions = ConstructorParameters[0] +type MfOptionsWithEmail = MfOptions & { + bindings?: MiniflareOptions['bindings'] + d1Databases?: MiniflareOptions['d1Databases'] + d1Persist?: string + durableObjects?: MiniflareOptions['durableObjects'] + durableObjectsPersist?: string + email?: MiniflareSendEmailConfig + kvNamespaces?: MiniflareOptions['kvNamespaces'] + kvPersist?: string + queueProducers?: Record + ratelimits?: MiniflareOptions['rateLimits'] + versionMetadata?: string + workerLoaders?: MiniflareOptions['workerLoaders'] + mtlsCertificates?: MiniflareOptions['mtlsCertificates'] + dispatchNamespaces?: MiniflareOptions['dispatchNamespaces'] + workflows?: MiniflareOptions['workflows'] + workflowsPersist?: string + pipelines?: MiniflareOptions['pipelines'] + images?: MiniflareOptions['images'] + imagesPersist?: string + media?: MiniflareOptions['media'] + artifacts?: MiniflareOptions['artifacts'] + secretsStoreSecrets?: MiniflareOptions['secretsStore'] + serviceBindings?: MiniflareOptions['serviceBindings'] + wrappedBindings?: MiniflareOptions['wrappedBindings'] + workers?: Array> + r2Buckets?: MiniflareOptions['r2Buckets'] + r2Persist?: string +} + +// ----------------------------------------------------------------------------- +// Gateway Worker Script +// ----------------------------------------------------------------------------- + +/** + * Generates a lightweight HTTP-only gateway worker script for + * `startMiniflare()` usage (tests, scripts, programmatic access). + * + * The RPC dispatch logic is shared with `dev-server/gateway-script.ts` via + * `GATEWAY_RUNTIME_JS`. This gateway exposes the dispatcher over a plain + * HTTP endpoint (`POST /_devflare/rpc`). The full WebSocket bridge with + * streaming and WebSocket proxying lives in `./server.ts` / the dev-server + * gateway. + */ +function generateGatewayScript(): string { + return ` +${GATEWAY_RUNTIME_JS} + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url) + + if (url.pathname === '/_devflare/health') { + return new Response(JSON.stringify({ ok: true, status: 'ok', bindings: Object.keys(env) }), { + headers: { 'Content-Type': 'application/json' } + }) + } + + if (url.pathname === '/_devflare/rpc' && request.method === 'POST') { + try { + const { method, params } = await request.json() + const result = await executeRpcMethod(method, params, env, ctx) + return new Response(JSON.stringify({ ok: true, result }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + error: { code: error?.code || 'RPC_ERROR', message: error?.message || String(error) } + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + } + + return new Response('Devflare Gateway', { status: 200 }) + } +} +` +} + +function hasNamedBindings(bindings: string[] | Record | undefined): boolean { + if (!bindings) { + return false + } + + if (Array.isArray(bindings)) { + return bindings.length > 0 + } + + return Object.keys(bindings).length > 0 +} + +function resolvePersistPath(options: MiniflareOptions): string | undefined { + if (!options.persist) { + return undefined + } + + if (typeof options.persist === 'string' && options.persist.trim().length > 0) { + return options.persist + } + + return options.persistPath ?? '.devflare/data' +} + +async function loadMiniflareRuntime() { + return await import('miniflare') +} + +function createBaseMiniflareConfig( + options: MiniflareOptions, + runtime: MiniflareRuntime +): MfOptionsWithEmail { + const config: MfOptionsWithEmail = { + modules: true, + script: generateGatewayScript(), + port: options.port ?? 8787, + host: '127.0.0.1', + compatibilityDate: options.compatibilityDate ?? '2024-01-01', + compatibilityFlags: options.compatibilityFlags ?? [] + } + + const log = createMiniflareLog( + runtime.Log, + runtime.LogLevel, + options.verbose ? 'DEBUG' : 'WARN' + ) + if (log) { + config.log = log as MfOptionsWithEmail['log'] + } + + return config +} + +function applyKVNamespaceConfig( + config: MfOptionsWithEmail, + kvNamespaces: MiniflareOptions['kvNamespaces'], + persistPath: string | undefined +): void { + if (!hasNamedBindings(kvNamespaces)) { + return + } + + config.kvNamespaces = kvNamespaces + if (persistPath) { + config.kvPersist = `${persistPath}/kv` + } +} + +function applyR2BucketConfig( + config: MfOptionsWithEmail, + r2Buckets: MiniflareOptions['r2Buckets'], + persistPath: string | undefined +): void { + if (!hasNamedBindings(r2Buckets)) { + return + } + + config.r2Buckets = r2Buckets + if (persistPath) { + config.r2Persist = `${persistPath}/r2` + } +} + +function applyD1DatabaseConfig( + config: MfOptionsWithEmail, + d1Databases: MiniflareOptions['d1Databases'], + persistPath: string | undefined +): void { + if (!hasNamedBindings(d1Databases)) { + return + } + + config.d1Databases = d1Databases + if (persistPath) { + config.d1Persist = `${persistPath}/d1` + } +} + +function applyDurableObjectConfig( + config: MfOptionsWithEmail, + durableObjects: MiniflareOptions['durableObjects'], + persistPath: string | undefined +): void { + if (!durableObjects) { + return + } + + config.durableObjects = durableObjects + if (persistPath) { + config.durableObjectsPersist = `${persistPath}/do` + } +} + +function applySendEmailConfig( + config: MfOptionsWithEmail, + sendEmail: MiniflareOptions['sendEmail'] +): void { + if (!sendEmail) { + return + } + + config.email = { + send_email: Object.entries(sendEmail).map(([name, emailConfig]) => ({ + name, + ...(emailConfig.destinationAddress && { + destination_address: emailConfig.destinationAddress + }), + ...(emailConfig.allowedDestinationAddresses && { + allowed_destination_addresses: emailConfig.allowedDestinationAddresses + }), + ...(emailConfig.allowedSenderAddresses && { + allowed_sender_addresses: emailConfig.allowedSenderAddresses + }) + })) + } +} + +function applyBindingsConfig( + config: MfOptionsWithEmail, + bindings: MiniflareOptions['bindings'] +): void { + if (!bindings || Object.keys(bindings).length === 0) { + return + } + + config.bindings = bindings +} + +function applyQueueConfig(config: MfOptionsWithEmail, queues: MiniflareOptions['queues']): void { + if (!queues?.length) { + return + } + + config.queueProducers = Object.fromEntries(queues.map((queueName) => [queueName, { queueName }])) +} + +function applyRateLimitConfig( + config: MfOptionsWithEmail, + rateLimits: MiniflareOptions['rateLimits'] +): void { + if (!rateLimits || Object.keys(rateLimits).length === 0) { + return + } + + config.ratelimits = rateLimits +} + +function applyVersionMetadataConfig( + config: MfOptionsWithEmail, + versionMetadata: MiniflareOptions['versionMetadata'] +): void { + if (!versionMetadata) { + return + } + + config.versionMetadata = versionMetadata +} + +function applyWorkerLoaderConfig( + config: MfOptionsWithEmail, + workerLoaders: MiniflareOptions['workerLoaders'] +): void { + if (!workerLoaders || Object.keys(workerLoaders).length === 0) { + return + } + + config.workerLoaders = workerLoaders +} + +function applyMtlsCertificateConfig( + config: MfOptionsWithEmail, + mtlsCertificates: MiniflareOptions['mtlsCertificates'] +): void { + if (!mtlsCertificates || Object.keys(mtlsCertificates).length === 0) { + return + } + + config.mtlsCertificates = mtlsCertificates +} + +function applyDispatchNamespaceConfig( + config: MfOptionsWithEmail, + dispatchNamespaces: MiniflareOptions['dispatchNamespaces'] +): void { + if (!dispatchNamespaces || Object.keys(dispatchNamespaces).length === 0) { + return + } + + config.dispatchNamespaces = dispatchNamespaces +} + +function applyWorkflowConfig( + config: MfOptionsWithEmail, + workflows: MiniflareOptions['workflows'], + persistPath: string | undefined +): void { + if (!workflows || Object.keys(workflows).length === 0) { + return + } + + config.workflows = workflows + if (persistPath) { + config.workflowsPersist = `${persistPath}/workflows` + } +} + +function applyPipelineConfig( + config: MfOptionsWithEmail, + pipelines: MiniflareOptions['pipelines'] +): void { + if (!pipelines || Object.keys(pipelines).length === 0) { + return + } + + config.pipelines = pipelines +} + +function applyImagesConfig( + config: MfOptionsWithEmail, + images: MiniflareOptions['images'], + persistPath: string | undefined +): void { + if (!images) { + return + } + + config.images = images + if (persistPath) { + config.imagesPersist = `${persistPath}/images` + } +} + +function applyMediaConfig(config: MfOptionsWithEmail, media: MiniflareOptions['media']): void { + if (!media) { + return + } + + config.media = media +} + +function applyArtifactsConfig( + config: MfOptionsWithEmail, + artifacts: MiniflareOptions['artifacts'] +): void { + if (!artifacts || Object.keys(artifacts).length === 0) { + return + } + + config.artifacts = artifacts +} + +function applySecretsStoreConfig( + config: MfOptionsWithEmail, + secretsStore: MiniflareOptions['secretsStore'] +): void { + if (!secretsStore || Object.keys(secretsStore).length === 0) { + return + } + + config.secretsStoreSecrets = secretsStore +} + +function applyServiceBindingsConfig( + config: MfOptionsWithEmail, + serviceBindings: MiniflareOptions['serviceBindings'] +): void { + if (!serviceBindings || Object.keys(serviceBindings).length === 0) { + return + } + + config.serviceBindings = serviceBindings +} + +function applyWrappedBindingsConfig( + config: MfOptionsWithEmail, + wrappedBindings: MiniflareOptions['wrappedBindings'] +): void { + if (!wrappedBindings || Object.keys(wrappedBindings).length === 0) { + return + } + + config.wrappedBindings = wrappedBindings +} + +function createConfigWithAuxiliaryWorkers( + config: MfOptionsWithEmail, + auxiliaryWorkers: MiniflareOptions['auxiliaryWorkers'] +): MfOptionsWithEmail { + if (!auxiliaryWorkers || auxiliaryWorkers.length === 0) { + return config + } + + const { + port, + host, + log, + kvPersist, + r2Persist, + d1Persist, + durableObjectsPersist, + workflowsPersist, + imagesPersist, + ...primaryWorker + } = config + const primaryWorkerRecord = primaryWorker as Record + const primaryWorkerName = typeof primaryWorkerRecord.name === 'string' + ? primaryWorkerRecord.name + : 'devflare-gateway' + + return { + ...(port !== undefined && { port }), + ...(host && { host }), + ...(log && { log }), + ...(kvPersist && { kvPersist }), + ...(r2Persist && { r2Persist }), + ...(d1Persist && { d1Persist }), + ...(durableObjectsPersist && { durableObjectsPersist }), + ...(workflowsPersist && { workflowsPersist }), + ...(imagesPersist && { imagesPersist }), + workers: [ + { + ...primaryWorkerRecord, + name: primaryWorkerName + }, + ...auxiliaryWorkers + ] + } as unknown as MfOptionsWithEmail +} + +function createMiniflareConfig( + options: MiniflareOptions, + runtime: MiniflareRuntime +): MfOptionsWithEmail { + const persistPath = resolvePersistPath(options) + const config = createBaseMiniflareConfig(options, runtime) + + applyKVNamespaceConfig(config, options.kvNamespaces, persistPath) + applyR2BucketConfig(config, options.r2Buckets, persistPath) + applyD1DatabaseConfig(config, options.d1Databases, persistPath) + applyDurableObjectConfig(config, options.durableObjects, persistPath) + applySendEmailConfig(config, options.sendEmail) + applyBindingsConfig(config, options.bindings) + applyQueueConfig(config, options.queues) + applyRateLimitConfig(config, options.rateLimits) + applyVersionMetadataConfig(config, options.versionMetadata) + applyWorkerLoaderConfig(config, options.workerLoaders) + applyMtlsCertificateConfig(config, options.mtlsCertificates) + applyDispatchNamespaceConfig(config, options.dispatchNamespaces) + applyWorkflowConfig(config, options.workflows, persistPath) + applyPipelineConfig(config, options.pipelines) + applyImagesConfig(config, options.images, persistPath) + applyMediaConfig(config, options.media) + applyArtifactsConfig(config, options.artifacts) + applySecretsStoreConfig(config, options.secretsStore) + applyServiceBindingsConfig(config, options.serviceBindings) + applyWrappedBindingsConfig(config, options.wrappedBindings) + + return createConfigWithAuxiliaryWorkers(config, options.auxiliaryWorkers) +} + +function bindMiniflareMethod( + mf: MiniflareType, + methodName: TMethodName +): MiniflareType[TMethodName] { + const method = mf[methodName] + if (typeof method === 'function') { + return method.bind(mf) as MiniflareType[TMethodName] + } + + return (async () => { + throw new Error(`Miniflare runtime does not expose ${String(methodName)}`) + }) as unknown as MiniflareType[TMethodName] +} + +function getPrimaryWorkerName(config: MfOptionsWithEmail): string | undefined { + const [primaryWorker] = config.workers ?? [] + const name = primaryWorker?.name + return typeof name === 'string' ? name : undefined +} + +export function createMiniflareInstanceHandle( + mf: MiniflareType, + primaryWorkerName?: string, + nodeBindingOverrides: Record = {} +): MiniflareInstance { + return { + ready: Promise.resolve(), + + async dispose() { + const dispose = (mf as { dispose?: unknown }).dispose + if (typeof dispose !== 'function') { + return + } + + try { + await dispose.call(mf) + } catch (error) { + if (!isIgnorableMiniflareDisposeError(error)) { + throw error + } + } + }, + + async getBindings() { + const bindings = primaryWorkerName ? await mf.getBindings(primaryWorkerName) : await mf.getBindings() + return { + ...bindings, + ...nodeBindingOverrides + } + }, + + getKVNamespace: bindMiniflareMethod(mf, 'getKVNamespace'), + getR2Bucket: bindMiniflareMethod(mf, 'getR2Bucket'), + getD1Database: bindMiniflareMethod(mf, 'getD1Database'), + getDurableObjectNamespace: bindMiniflareMethod(mf, 'getDurableObjectNamespace'), + dispatchFetch: bindMiniflareMethod(mf, 'dispatchFetch'), + + _mf: mf + } +} + +// ----------------------------------------------------------------------------- +// Miniflare Instance Creation +// ----------------------------------------------------------------------------- + +/** + * Start a Miniflare instance with the given configuration + */ +export async function startMiniflare(options: MiniflareOptions = {}): Promise { + const runtime = await loadMiniflareRuntime() + const mfConfig = createMiniflareConfig(options, runtime) + const mf = new runtime.Miniflare(mfConfig as MfOptions) + await mf.ready + + return createMiniflareInstanceHandle(mf, getPrimaryWorkerName(mfConfig), options.nodeBindingOverrides) +} + +// ----------------------------------------------------------------------------- +// Config-based Miniflare Creation +// ----------------------------------------------------------------------------- + +/** + * Start Miniflare from a devflare config + */ +export async function startMiniflareFromConfig( + config: DevflareConfig, + options: Partial = {} +): Promise { + const runtimeConfig = options.cwd + ? await applyLocalDevVarsToConfig( + await resolveConfigEnvVars(config, { + cwd: options.cwd, + configPath: options.configPath, + mode: 'dev' + }), + { + cwd: options.cwd, + configPath: options.configPath, + environment: options.environment + } + ) + : config + const bindings = runtimeConfig.bindings ?? {} + const localSecretWrappedBindingConfig = options.cwd + ? buildLocalSecretWrappedBindingConfig(runtimeConfig, options.cwd) + : undefined + const localSecretNodeBindings = options.cwd + ? buildLocalSecretNodeBindings(runtimeConfig, options.cwd) + : undefined + const localSecretBindingNames = new Set(localSecretWrappedBindingConfig?.localBindingNames ?? []) + const localBindingShimServiceConfig = buildLocalBindingShimServiceConfig(runtimeConfig) + const wrappedBindings = { + ...(localSecretWrappedBindingConfig?.wrappedBindings ?? {}) + } + const auxiliaryWorkers = [ + ...(localSecretWrappedBindingConfig?.workers ?? []), + ...localBindingShimServiceConfig.workers + ] + + // For Miniflare, pass the full mapping to ensure consistent namespace/database IDs + const mfOptions: MiniflareOptions = { + ...options, + compatibilityDate: runtimeConfig.compatibilityDate, + compatibilityFlags: runtimeConfig.compatibilityFlags, + kvNamespaces: bindings.kv + ? Object.fromEntries( + Object.entries(bindings.kv).map(([bindingName, bindingConfig]) => { + return [bindingName, getLocalKVNamespaceIdentifier(bindingConfig)] + }) + ) + : undefined, + r2Buckets: bindings.r2 ? bindings.r2 : undefined, + d1Databases: bindings.d1 + ? Object.fromEntries( + Object.entries(bindings.d1).map(([bindingName, bindingConfig]) => { + return [bindingName, getLocalD1DatabaseIdentifier(bindingConfig)] + }) + ) + : undefined, + queues: bindings.queues?.consumers?.map((c) => c.queue), + rateLimits: bindings.rateLimits + ? Object.fromEntries( + Object.entries(bindings.rateLimits).map(([bindingName, binding]) => [ + bindingName, + { + simple: { + limit: binding.simple.limit, + period: binding.simple.period + } + } + ]) + ) + : undefined, + versionMetadata: bindings.versionMetadata?.binding, + workerLoaders: bindings.workerLoaders + ? Object.fromEntries( + Object.keys(bindings.workerLoaders).map((bindingName) => [bindingName, {}]) + ) + : undefined, + mtlsCertificates: bindings.mtlsCertificates + ? Object.fromEntries( + Object.entries(bindings.mtlsCertificates).map(([bindingName, binding]) => { + const normalized = normalizeMtlsCertificateBinding(binding) + return [ + bindingName, + { + certificate_id: normalized.certificateId + } + ] + }) + ) + : undefined, + dispatchNamespaces: bindings.dispatchNamespaces + ? Object.fromEntries( + Object.entries(bindings.dispatchNamespaces).map(([bindingName, binding]) => { + const normalized = normalizeDispatchNamespaceBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) + : undefined, + workflows: bindings.workflows + ? Object.fromEntries( + Object.entries(bindings.workflows).map(([bindingName, binding]) => { + const normalized = normalizeWorkflowBinding(binding) + return [ + bindingName, + { + name: normalized.name, + className: normalized.className, + ...(normalized.scriptName && { scriptName: normalized.scriptName }), + ...(normalized.limits && { stepLimit: normalized.limits.steps }) + } + ] + }) + ) + : undefined, + pipelines: bindings.pipelines + ? Object.fromEntries( + Object.entries(bindings.pipelines).map(([bindingName, binding]) => { + const normalized = normalizePipelineBinding(binding) + return [ + bindingName, + typeof binding === 'string' ? normalized.pipeline : { pipeline: normalized.pipeline } + ] + }) + ) + : undefined, + images: bindings.images + ? (() => { + if (localBindingShimServiceConfig.localBindingNames.length > 0) return undefined + const [entry] = Object.entries(bindings.images ?? {}) + if (!entry) return undefined + const [bindingName, binding] = entry + const normalized = normalizeImagesBinding(bindingName, binding) + return { binding: normalized.binding } + })() + : undefined, + media: bindings.media + ? (() => { + if (localBindingShimServiceConfig.localBindingNames.length > 0) return undefined + const [entry] = Object.entries(bindings.media ?? {}) + if (!entry) return undefined + const [bindingName, binding] = entry + const normalized = normalizeMediaBinding(bindingName, binding) + return { binding: normalized.binding } + })() + : undefined, + artifacts: bindings.artifacts + ? Object.fromEntries( + Object.entries(bindings.artifacts).map(([bindingName, binding]) => { + const normalized = normalizeArtifactsBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) + : undefined, + secretsStore: bindings.secretsStore + ? Object.fromEntries( + Object.entries(bindings.secretsStore).flatMap(([bindingName, binding]) => { + if (localSecretBindingNames.has(bindingName)) { + return [] + } + + const normalized = normalizeSecretsStoreBinding( + binding, + runtimeConfig.secretsStoreId, + bindingName + ) + return [[ + bindingName, + { + store_id: normalized.storeId, + secret_name: normalized.secretName + } + ]] + }) + ) + : undefined, + sendEmail: bindings.sendEmail ? bindings.sendEmail : undefined, + bindings: runtimeConfig.vars, + serviceBindings: { + ...(options.serviceBindings ?? {}), + ...localBindingShimServiceConfig.serviceBindings + }, + wrappedBindings: Object.keys(wrappedBindings).length > 0 ? wrappedBindings : undefined, + auxiliaryWorkers: auxiliaryWorkers.length > 0 ? auxiliaryWorkers : undefined, + nodeBindingOverrides: localSecretNodeBindings, + durableObjects: bindings.durableObjects + ? Object.fromEntries( + Object.entries(bindings.durableObjects).map(([bindingName, doConfig]) => { + const normalized = normalizeDOBinding(doConfig) + return [ + bindingName, + { + className: normalized.className, + scriptPath: normalized.scriptName + } + ] + }) + ) + : undefined + } + + return await startMiniflare(mfOptions) +} + +// ----------------------------------------------------------------------------- +// Singleton Instance Management +// ----------------------------------------------------------------------------- + +let globalMiniflare: MiniflareInstance | null = null + +/** + * Get or start the global Miniflare instance + */ +export async function getMiniflare(options?: MiniflareOptions): Promise { + if (!globalMiniflare) { + globalMiniflare = await startMiniflare(options) + } + return globalMiniflare +} + +/** + * Stop the global Miniflare instance + */ +export async function stopMiniflare(): Promise { + if (globalMiniflare) { + await globalMiniflare.dispose() + globalMiniflare = null + } +} diff --git a/packages/devflare/src/bridge/proxy.ts b/packages/devflare/src/bridge/proxy.ts new file mode 100644 index 0000000..8e724cb --- /dev/null +++ b/packages/devflare/src/bridge/proxy.ts @@ -0,0 +1,855 @@ +// ============================================================================= +// Bridge Proxy โ€” The Magic `env` Object +// ============================================================================= +// Creates a Proxy that transparently routes binding calls through the bridge +// ============================================================================= + +import { getClient, type BridgeClient } from './client' +import { HTTP_TRANSFER_THRESHOLD } from './v2/wire' +import { + deserializeValue, + serializeRequest, + deserializeResponse, + type SerializedResponse +} from './v2/value-serialization' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface EnvProxyOptions { + /** Bridge client to use (uses default if not provided) */ + client?: BridgeClient + /** Lazily connect on first access */ + lazy?: boolean + /** Return undefined for names that are not present in binding hints */ + strict?: boolean + /** Transform results before returning (e.g., for transport decoding) */ + transformResult?: (result: unknown) => unknown +} + +// ----------------------------------------------------------------------------- +// KV Namespace Proxy +// ----------------------------------------------------------------------------- + +// Note: We use `any` at RPC boundaries because: +// 1. Values are serialized and sent cross-runtime (Node.js โ†’ workerd) +// 2. Cloudflare's KVNamespace types use generics that can't be preserved across RPC +// 3. The actual type safety is enforced by the real binding in Miniflare + +function createKVProxy(client: BridgeClient, bindingName: string): KVNamespace { + return { + async get(key: string, options?: any): Promise { + return client.call(`${bindingName}.kv.get`, [key, options]) + }, + async put(key: string, value: any, options?: any): Promise { + await client.call(`${bindingName}.kv.put`, [key, value, options]) + }, + async delete(key: string): Promise { + await client.call(`${bindingName}.kv.delete`, [key]) + }, + async list(options?: any): Promise { + return client.call(`${bindingName}.kv.list`, [options]) + }, + async getWithMetadata(key: string, options?: any): Promise { + return client.call(`${bindingName}.kv.getWithMetadata`, [key, options]) + } + } as KVNamespace +} + +// ----------------------------------------------------------------------------- +// R2 Bucket Proxy +// ----------------------------------------------------------------------------- + +function createR2Proxy(client: BridgeClient, bindingName: string): R2Bucket { + return { + async head(key: string): Promise { + return client.call(`${bindingName}.r2.head`, [key]) as Promise + }, + async get(key: string, options?: any): Promise { + return client.call(`${bindingName}.r2.get`, [key, options]) as Promise + }, + async put(key: string, value: any, options?: any): Promise { + // Check if value is large enough to use HTTP transfer + const size = getValueSize(value) + if (size > HTTP_TRANSFER_THRESHOLD) { + // Use HTTP transfer for large files + // Send to Miniflare gateway which handles the transfer + const transferId = `${bindingName}:${key}` + const httpUrl = client.getHttpUrl() + const transferUrl = httpUrl.replace(/\/$/, '') + `/_devflare/transfer/${encodeURIComponent(transferId)}` + + // Upload via HTTP directly to the gateway + const response = await fetch(transferUrl, { + method: 'PUT', + body: value, + headers: { + ...(options?.httpMetadata?.contentType + ? { 'Content-Type': options.httpMetadata.contentType } + : {}) + } + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`HTTP transfer failed: ${error}`) + } + + const serialized = await response.json() as unknown + return deserializeValue(serialized) as R2Object | null + } + + return client.call(`${bindingName}.r2.put`, [key, value, options]) as Promise + }, + async delete(keys: string | string[]): Promise { + await client.call(`${bindingName}.r2.delete`, [keys]) + }, + async list(options?: any): Promise { + return client.call(`${bindingName}.r2.list`, [options]) as Promise + }, + async createMultipartUpload(key: string, options?: any): Promise { + return client.call(`${bindingName}.r2.createMultipartUpload`, [key, options]) as Promise + }, + async resumeMultipartUpload(key: string, uploadId: string): Promise { + return client.call(`${bindingName}.r2.resumeMultipartUpload`, [key, uploadId]) as Promise + } + } as unknown as R2Bucket +} + +function getValueSize(value: unknown): number { + if (value instanceof Blob) return value.size + if (value instanceof ArrayBuffer) return value.byteLength + if (value instanceof Uint8Array) return value.byteLength + if (typeof value === 'string') return new TextEncoder().encode(value).byteLength + if (value instanceof ReadableStream) return Infinity // Assume large + return 0 +} + +// ----------------------------------------------------------------------------- +// D1 Database Proxy +// ----------------------------------------------------------------------------- + +function createD1Proxy(client: BridgeClient, bindingName: string): D1Database { + return { + prepare(sql: string): D1PreparedStatement { + return createD1StatementProxy(client, bindingName, sql, []) + }, + async batch(statements: D1PreparedStatement[]): Promise { + // Serialize statements + const serialized = statements.map((stmt) => { + const s = stmt as any + return { sql: s._sql, bindings: s._bindings } + }) + return client.call(`${bindingName}.d1.batch`, [serialized]) + }, + async exec(sql: string): Promise { + return client.call(`${bindingName}.d1.exec`, [sql]) + }, + async dump(): Promise { + return client.call(`${bindingName}.d1.dump`, []) as Promise + } + } as D1Database +} + +function createD1StatementProxy( + client: BridgeClient, + bindingName: string, + sql: string, + bindings: unknown[] +): D1PreparedStatement { + const stmt = { + _sql: sql, + _bindings: bindings, + bind(...values: unknown[]): D1PreparedStatement { + return createD1StatementProxy(client, bindingName, sql, values) + }, + async first(column?: string): Promise { + return client.call(`${bindingName}.d1.stmt.first`, [sql, ...bindings, column]) + }, + async all(): Promise { + return client.call(`${bindingName}.d1.stmt.all`, [sql, ...bindings]) + }, + async run(): Promise { + return client.call(`${bindingName}.d1.stmt.run`, [sql, ...bindings]) + }, + async raw(options?: any): Promise { + return client.call(`${bindingName}.d1.stmt.raw`, [sql, ...bindings, options]) + } + } + return stmt as D1PreparedStatement +} + +// ----------------------------------------------------------------------------- +// Durable Object Namespace Proxy +// ----------------------------------------------------------------------------- + +interface DOProxyOptions { + transformResult?: (result: unknown) => unknown +} + +function createDOProxy( + client: BridgeClient, + bindingName: string, + proxyOptions: DOProxyOptions = {} +): DurableObjectNamespace & { getByName(name: string): DurableObjectStub } { + return { + idFromName(name: string): DurableObjectId { + // Create a local ID reference that will be used in RPC calls + return createDOIdProxy(client, bindingName, { type: 'name', value: name }) + }, + idFromString(hexId: string): DurableObjectId { + return createDOIdProxy(client, bindingName, { type: 'hex', value: hexId }) + }, + newUniqueId(options?: any): DurableObjectId { + // Generate a unique ID locally (this will be synced on first use) + const tempId = crypto.randomUUID().replace(/-/g, '') + return createDOIdProxy(client, bindingName, { type: 'unique', value: tempId, options }) + }, + get(id: DurableObjectId): DurableObjectStub { + const idProxy = id as DOIdProxy + return createDOStubProxy(client, bindingName, idProxy._idInfo, proxyOptions) + }, + /** + * Convenience method: Get a stub directly by name + * Equivalent to: namespace.get(namespace.idFromName(name)) + */ + getByName(name: string): DurableObjectStub { + const id = this.idFromName(name) + return this.get(id) + }, + jurisdiction(jurisdiction: string): DurableObjectNamespace { + // Return a new proxy with jurisdiction info + return createDOProxy(client, bindingName, proxyOptions) // TODO: Add jurisdiction support + } + } as DurableObjectNamespace & { getByName(name: string): DurableObjectStub } +} + +interface DOIdInfo { + type: 'name' | 'hex' | 'unique' + value: string + options?: any +} + +interface DOIdProxy extends DurableObjectId { + _idInfo: DOIdInfo +} + +function createDOIdProxy(client: BridgeClient, bindingName: string, idInfo: DOIdInfo): DurableObjectId { + return { + _idInfo: idInfo, + toString(): string { + if (idInfo.type === 'hex') return idInfo.value + // For name-based IDs, we need to get the actual hex ID from the server + // This is a limitation - toString() is sync but we need async + return `${idInfo.type}:${idInfo.value}` + }, + equals(other: DurableObjectId): boolean { + return this.toString() === other.toString() + } + } as DOIdProxy +} + +function createDOStubProxy( + client: BridgeClient, + bindingName: string, + idInfo: DOIdInfo, + proxyOptions: DOProxyOptions = {} +): DurableObjectStub { + const { transformResult } = proxyOptions + + // Resolve the ID first + let resolvedId: any = null + const resolveId = async () => { + if (resolvedId) return resolvedId + switch (idInfo.type) { + case 'name': + resolvedId = await client.call(`${bindingName}.do.idFromName`, [idInfo.value]) + break + case 'hex': + resolvedId = { __type: 'DOId', hex: idInfo.value } + break + case 'unique': + resolvedId = await client.call(`${bindingName}.do.newUniqueId`, [idInfo.options]) + break + } + return resolvedId + } + + // Create a proxy that intercepts method calls for RPC + const stubBase = { + async fetch(input: RequestInfo, init?: RequestInit): Promise { + const id = await resolveId() + const request = input instanceof Request ? input : new Request(input, init) + + const { serialized } = await serializeRequest(request) + const result = await client.call(`${bindingName}.do.fetch`, [bindingName, id, serialized]) + + // Deserialize response + return deserializeResponse(result as SerializedResponse) + }, + + /** + * Connect to the Durable Object via WebSocket (hibernation pattern) + * + * This creates a proxied WebSocket connection through the bridge. + * The DO must implement webSocketMessage/webSocketClose handlers. + */ + async connect(url: string, options?: { headers?: HeadersInit }): Promise { + const id = await resolveId() + + // Extract headers as array of tuples + const headersList: [string, string][] = [] + if (options?.headers) { + const headers = new Headers(options.headers) + headers.forEach((value, key) => { + headersList.push([key, value]) + }) + } + + // Create WebSocket proxy via bridge + const wsProxy = await client.createWsProxy( + bindingName, + id.hex, + url, + headersList + ) + + // Create readable stream from WS messages + let readController: ReadableStreamDefaultController | null = null + const readable = new ReadableStream({ + start(controller) { + readController = controller + }, + cancel() { + wsProxy.close() + } + }) + + // Set up message handler + wsProxy.onMessage((data) => { + if (readController) { + const chunk = typeof data === 'string' + ? new TextEncoder().encode(data) + : data + readController.enqueue(chunk) + } + }) + + // Set up close handler + wsProxy.onClose((code, reason) => { + if (readController) { + readController.close() + } + }) + + // Create writable stream for sending + const writable = new WritableStream({ + write(chunk) { + wsProxy.send(chunk) + }, + close() { + wsProxy.close(1000, 'Normal closure') + }, + abort(reason) { + wsProxy.close(1001, reason?.toString() ?? 'Aborted') + } + }) + + // Return Socket-like object + return { + readable, + writable, + get opened() { + return Promise.resolve({ + remoteAddress: '127.0.0.1', + localAddress: '127.0.0.1' + }) + }, + get closed() { + return new Promise((resolve) => { + wsProxy.onClose(() => resolve()) + }) + }, + close() { + wsProxy.close(1000, 'Normal closure') + return Promise.resolve() + }, + startTls() { + throw new Error('startTls not supported on DO WebSocket proxy') + } + } as unknown as Socket + }, + + get id(): DurableObjectId { + return createDOIdProxy(client, bindingName, idInfo) + }, + + get name(): string | undefined { + return idInfo.type === 'name' ? idInfo.value : undefined + } + } + + // Return a Proxy that intercepts any method call and routes to RPC + return new Proxy(stubBase, { + get(target, prop: string | symbol) { + // Return known properties from the base stub + if (prop in target) { + return (target as any)[prop] + } + + // Symbol properties - pass through + if (typeof prop === 'symbol') { + return undefined + } + + if (prop === 'then' || prop === 'catch' || prop === 'finally') { + return undefined + } + + // Any other property is treated as an RPC method + // Return a function that calls the DO via RPC + return async (...args: unknown[]) => { + const id = await resolveId() + let result = await client.call(`${bindingName}.do.rpc`, [ + bindingName, + id, + prop, + args + ]) + // Apply transport decoding if configured + if (transformResult) { + result = transformResult(result) + } + return result + } + } + }) as unknown as DurableObjectStub +} + +// ----------------------------------------------------------------------------- +// Service Binding Proxy +// ----------------------------------------------------------------------------- + +interface ServiceProxyOptions { + transformResult?: (result: unknown) => unknown +} + +function isResponseLike(value: unknown): value is Response { + return value instanceof Response +} + +function createServiceProxy( + client: BridgeClient, + bindingName: string, + proxyOptions: ServiceProxyOptions = {} +): Fetcher { + const { transformResult } = proxyOptions + + const serviceBase = { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = input instanceof Request ? input : new Request(input, init) + const { serialized } = await serializeRequest(request) + const result = await client.call(`${bindingName}.service.fetch`, [serialized]) + + if (isResponseLike(result)) { + return result + } + + return deserializeResponse(result as SerializedResponse) + } + } + + return new Proxy(serviceBase, { + get(target, prop: string | symbol) { + if (typeof prop !== 'string') { + return undefined + } + + if (prop in target) { + return (target as Record)[prop] + } + + if (prop === 'then' || prop === 'catch' || prop === 'finally') { + return undefined + } + + if (prop === 'toString') { + return () => `[DevflareServiceBinding ${bindingName}]` + } + + return async (...args: unknown[]) => { + let result = await client.call(`${bindingName}.service.rpc`, [prop, args]) + if (transformResult) { + result = transformResult(result) + } + return result + } + } + }) as unknown as Fetcher +} + +// ----------------------------------------------------------------------------- +// Queue Proxy +// ----------------------------------------------------------------------------- + +interface QueueMetricsSnapshot { + backlogCount: number + backlogBytes: number + oldestMessageTimestamp?: Date +} + +interface QueueResponseSnapshot { + metadata: { + metrics: QueueMetricsSnapshot + } +} + +const EMPTY_QUEUE_METRICS: QueueMetricsSnapshot = { + backlogCount: 0, + backlogBytes: 0 +} + +function createQueueResponse(): QueueResponseSnapshot { + return { metadata: { metrics: EMPTY_QUEUE_METRICS } } +} + +function createQueueProxy(client: BridgeClient, bindingName: string): Queue { + return { + async metrics(): Promise { + return EMPTY_QUEUE_METRICS + }, + async send(message: unknown, options?: unknown): Promise { + await client.call(`${bindingName}.queue.send`, [message, options]) + return createQueueResponse() + }, + async sendBatch(messages: Iterable, options?: unknown): Promise { + await client.call(`${bindingName}.queue.sendBatch`, [messages, options]) + return createQueueResponse() + } + } as unknown as Queue +} + +// ----------------------------------------------------------------------------- +// AI Proxy +// ----------------------------------------------------------------------------- + +function createAIProxy(client: BridgeClient, bindingName: string): any { + return { + async run(model: string, inputs: any, options?: any): Promise { + return client.call(`${bindingName}.ai.run`, [model, inputs, options]) + } + } +} + +// ----------------------------------------------------------------------------- +// Send Email Proxy +// ----------------------------------------------------------------------------- + +function createSendEmailProxy(client: BridgeClient, bindingName: string): SendEmail { + return { + async send(message: EmailMessage | { + from: string + to: string | string[] + subject: string + replyTo?: string | EmailAddress + cc?: string | string[] + bcc?: string | string[] + headers?: Record + text?: string + html?: string + attachments?: EmailAttachment[] + }): Promise { + return client.call(`${bindingName}.email.send`, [message]) as Promise + } + } as SendEmail +} + +// ----------------------------------------------------------------------------- +// Workflow Proxy +// ----------------------------------------------------------------------------- + +function createWorkflowInstanceProxy( + client: BridgeClient, + bindingName: string, + id: string +): WorkflowInstance { + return { + id, + async pause(): Promise { + await client.call(`${bindingName}.workflow.pause`, [id]) + }, + async resume(): Promise { + await client.call(`${bindingName}.workflow.resume`, [id]) + }, + async terminate(): Promise { + await client.call(`${bindingName}.workflow.terminate`, [id]) + }, + async restart(): Promise { + await client.call(`${bindingName}.workflow.restart`, [id]) + }, + async status(): Promise { + return client.call(`${bindingName}.workflow.status`, [id]) as Promise + }, + async sendEvent(event: { type: string; payload: unknown }): Promise { + await client.call(`${bindingName}.workflow.sendEvent`, [id, event]) + } + } as WorkflowInstance +} + +function createWorkflowProxy(client: BridgeClient, bindingName: string): Workflow { + const toInstance = (value: unknown): WorkflowInstance => { + const id = (value as { id?: unknown })?.id + if (typeof id !== 'string') { + throw new Error(`Workflow ${bindingName} returned an instance without a string id.`) + } + return createWorkflowInstanceProxy(client, bindingName, id) + } + + return { + async create(options?: WorkflowInstanceCreateOptions): Promise { + return toInstance(await client.call(`${bindingName}.workflow.create`, [options])) + }, + async get(id: string): Promise { + return toInstance(await client.call(`${bindingName}.workflow.get`, [id])) + } + } as Workflow +} + +// ----------------------------------------------------------------------------- +// Main Env Proxy +// ----------------------------------------------------------------------------- + +/** Binding type hints for better proxy creation */ +export type BindingHint = + | 'kv' + | 'r2' + | 'd1' + | 'do' + | 'queue' + | 'ai' + | 'service' + | 'sendEmail' + | 'workflow' + | 'secret' + | 'var' + +export interface BindingHints { + [key: string]: BindingHint +} + +/** Module-level storage for binding hints */ +let globalBindingHints: BindingHints = {} + +/** + * Create an env proxy that routes all binding access through the bridge + */ +export function createEnvProxy(options: EnvProxyOptions & { hints?: BindingHints } = {}): Record { + const client = options.client ?? getClient() + const bindingProxies = new Map() + const doProxyOptions: DOProxyOptions = { transformResult: options.transformResult } + const serviceProxyOptions: ServiceProxyOptions = { transformResult: options.transformResult } + const strict = options.strict === true + + // Merge provided hints with global hints (provided takes precedence) + const hints: BindingHints = { ...globalBindingHints, ...options.hints } + + return new Proxy({} as Record, { + get(target, prop: string | symbol) { + if (typeof prop !== 'string') return undefined + + // Return cached proxy + if (bindingProxies.has(prop)) { + return bindingProxies.get(prop) + } + + // Create proxy based on hint or default behavior + const hint = hints[prop] + if (!hint && strict) { + return undefined + } + + let proxy: unknown + + switch (hint) { + case 'kv': + proxy = createKVProxy(client, prop) + break + case 'r2': + proxy = createR2Proxy(client, prop) + break + case 'd1': + proxy = createD1Proxy(client, prop) + break + case 'do': + proxy = createDOProxy(client, prop, doProxyOptions) + break + case 'queue': + proxy = createQueueProxy(client, prop) + break + case 'ai': + proxy = createAIProxy(client, prop) + break + case 'service': + proxy = createServiceProxy(client, prop, serviceProxyOptions) + break + case 'sendEmail': + proxy = createSendEmailProxy(client, prop) + break + case 'workflow': + proxy = createWorkflowProxy(client, prop) + break + case 'secret': + case 'var': + // Simple values - need to fetch from server + proxy = createSimpleBindingProxy(client, prop) + break + default: + // Unknown binding - create a generic proxy that tries to detect type + proxy = createGenericBindingProxy(client, prop) + } + + bindingProxies.set(prop, proxy) + return proxy + }, + + has(target, prop: string | symbol) { + return typeof prop === 'string' && (!strict || prop in hints) + }, + + ownKeys() { + return Object.keys(hints) + }, + + getOwnPropertyDescriptor(target, prop) { + if (typeof prop === 'string' && (!strict || prop in hints)) { + return { configurable: true, enumerable: true, writable: false } + } + return undefined + } + }) +} + +// Generic proxy for unknown binding types +function createGenericBindingProxy(client: BridgeClient, bindingName: string): unknown { + return new Proxy({}, { + get(target, prop: string | symbol) { + if (typeof prop !== 'string') return undefined + + // Common KV methods + if (['get', 'put', 'delete', 'list', 'getWithMetadata'].includes(prop)) { + return createKVProxy(client, bindingName)[prop as keyof KVNamespace] + } + + // Common DO methods + if (['idFromName', 'idFromString', 'newUniqueId', 'get'].includes(prop)) { + return createDOProxy(client, bindingName)[prop as keyof DurableObjectNamespace] + } + + // Common D1 methods + if (['prepare', 'batch', 'exec', 'dump'].includes(prop)) { + return createD1Proxy(client, bindingName)[prop as keyof D1Database] + } + + // Common R2 methods + if (['head'].includes(prop)) { + return createR2Proxy(client, bindingName)[prop as keyof R2Bucket] + } + + // Fallback: call as generic method + return async (...args: unknown[]) => { + return client.call(`${bindingName}.${prop}`, args) + } + } + }) +} + +// Simple binding proxy (for secrets/vars) +function createSimpleBindingProxy(client: BridgeClient, bindingName: string): unknown { + // Return a thenable that fetches the value on await + let cachedValue: unknown + let fetched = false + let pendingValue: Promise | null = null + + const loadValue = () => { + if (fetched) { + return Promise.resolve(cachedValue) + } + + if (!pendingValue) { + pendingValue = client.call(`${bindingName}.var.value`, []) + .then((value) => { + cachedValue = value + fetched = true + return value + }) + .catch((error) => { + pendingValue = null + throw error + }) + } + + return pendingValue + } + + return { + then( + resolve?: ((value: unknown) => unknown) | null, + reject?: ((error: unknown) => unknown) | null + ) { + return loadValue().then(resolve ?? undefined, reject ?? undefined) + }, + toString() { + if (!fetched) throw new Error(`Binding ${bindingName} not yet fetched. Use await.`) + return String(cachedValue) + } + } +} + +// ----------------------------------------------------------------------------- +// Global env Export +// ----------------------------------------------------------------------------- + +let globalEnvProxy: Record | null = null + +/** + * Get the global env proxy for bridge RPC. + * + * @internal + * Internal bridge surface โ€” not part of the documented public API. Prefer + * `import { env } from 'devflare'`, which transparently picks the right + * source (request context, test context, or bridge) for the current + * environment. `bridgeEnv` is retained as an internal escape hatch for the + * bridge implementation itself and may change without a major version bump. + * + * @example + * ```ts + * await bridgeEnv.MY_KV.get('key') + * await bridgeEnv.MY_DO.get(id).fetch(request) + * ``` + */ +export const bridgeEnv: Record = new Proxy({} as Record, { + get(target, prop: string | symbol) { + if (!globalEnvProxy) { + globalEnvProxy = createEnvProxy({ lazy: true }) + } + return (globalEnvProxy as any)[prop] + } +}) + +/** + * Initialize the env proxy with specific options + */ +export function initEnv(options: EnvProxyOptions = {}): Record { + globalEnvProxy = createEnvProxy(options) + return globalEnvProxy +} + +/** + * Set binding hints for better proxy creation + * Hints help the bridge create optimized proxies for each binding type + */ +export function setBindingHints(hints: BindingHints): void { + globalBindingHints = { ...globalBindingHints, ...hints } + // Clear cached proxies so they're recreated with new hints + globalEnvProxy = null +} diff --git a/packages/devflare/src/bridge/server.ts b/packages/devflare/src/bridge/server.ts new file mode 100644 index 0000000..cafa658 --- /dev/null +++ b/packages/devflare/src/bridge/server.ts @@ -0,0 +1,880 @@ +// ============================================================================= +// Bridge Gateway Worker โ€” Runs Inside Miniflare +// ============================================================================= +// Receives RPC calls from the bridge client and executes them against bindings +// ============================================================================= + +import { + type JsonMsg, + type RpcCall, + type RpcOk, + type RpcErr, + type StreamOpen, + type StreamPull, + type WsOpen, + type WsClose, + parseJsonMsg, + stringifyJsonMsg, + encodeBinaryFrame, + decodeBinaryFrame, + BinaryKind, + BinaryFlags +} from './v2/wire' +import { + serializeValue, + deserializeValue, + deserializeRequest, + serializeDOId, + deserializeDOId, + base64Decode, + base64Encode, + type SerializedRequest, + type StreamRef +} from './v2/value-serialization' +import { normalizeSendEmailMessage } from '../utils/send-email' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface GatewayEnv { + // All bindings are passed dynamically + [key: string]: unknown +} + +interface ActiveStream { + reader: ReadableStreamDefaultReader + seq: number +} + +interface ActiveWsProxy { + doWs: WebSocket + clientWid: number +} + +// ----------------------------------------------------------------------------- +// Gateway Worker +// ----------------------------------------------------------------------------- + +export default { + async fetch(request: Request, env: GatewayEnv, ctx: ExecutionContext): Promise { + const url = new URL(request.url) + + // WebSocket upgrade for RPC bridge + if (request.headers.get('Upgrade') === 'websocket') { + return handleWebSocket(request, env, ctx) + } + + // HTTP endpoint for large file transfers + if (url.pathname.startsWith('/_devflare/transfer/')) { + return handleHttpTransfer(request, env, url) + } + + // Health check + if (url.pathname === '/_devflare/health') { + return new Response(JSON.stringify({ ok: true, bindings: Object.keys(env) }), { + headers: { 'Content-Type': 'application/json' } + }) + } + + return new Response('Devflare Bridge Gateway', { status: 200 }) + } +} + +// ----------------------------------------------------------------------------- +// WebSocket Handler +// ----------------------------------------------------------------------------- + +async function handleWebSocket( + request: Request, + env: GatewayEnv, + ctx: ExecutionContext +): Promise { + const { 0: client, 1: server } = new WebSocketPair() + + const activeStreams = new Map() + const wsProxies = new Map() + const incomingStreams = new Map + stream: ReadableStream + }>() + + server.accept() + + server.addEventListener('message', async (event) => { + try { + if (typeof event.data === 'string') { + await handleJsonMessage(event.data, server, env, ctx, activeStreams, wsProxies, incomingStreams) + } else if (event.data instanceof ArrayBuffer) { + handleBinaryMessage(new Uint8Array(event.data), server, wsProxies, incomingStreams) + } + } catch (error) { + console.error('[devflare bridge] message handler error:', error) + try { + server.send(JSON.stringify({ type: 'error', message: String(error) })) + } catch { + // best-effort notification; swallow send failures to keep listener alive + } + } + }) + + server.addEventListener('close', () => { + // Clean up streams + for (const [_sid, stream] of activeStreams) { + stream.reader.cancel() + } + activeStreams.clear() + + // Clean up WS proxies + for (const [_wid, proxy] of wsProxies) { + proxy.doWs.close() + } + wsProxies.clear() + }) + + return new Response(null, { status: 101, webSocket: client }) +} + +// ----------------------------------------------------------------------------- +// JSON Message Handler +// ----------------------------------------------------------------------------- + +async function handleJsonMessage( + data: string, + ws: WebSocket, + env: GatewayEnv, + ctx: ExecutionContext, + activeStreams: Map, + wsProxies: Map, + incomingStreams: Map; stream: ReadableStream }> +): Promise { + const msg = parseJsonMsg(data) + + switch (msg.t) { + case 'rpc.call': + await handleRpcCall(msg, ws, env, ctx, activeStreams, incomingStreams) + break + case 'stream.pull': + await handleStreamPull(msg, ws, activeStreams) + break + case 'stream.open': + handleStreamOpen(msg, incomingStreams) + break + case 'stream.end': + handleStreamEnd(msg, incomingStreams) + break + case 'stream.abort': + handleStreamAbort(msg, incomingStreams) + break + case 'ws.open': + await handleWsOpen(msg, ws, env, wsProxies) + break + case 'ws.close': + handleWsClose(msg, wsProxies) + break + } +} + +// ----------------------------------------------------------------------------- +// RPC Handler +// ----------------------------------------------------------------------------- + +async function handleRpcCall( + msg: RpcCall, + ws: WebSocket, + env: GatewayEnv, + ctx: ExecutionContext, + activeStreams: Map, + incomingStreams: Map; stream: ReadableStream }> +): Promise { + try { + // Deserialize params + const getStream = (sid: number) => incomingStreams.get(sid)?.stream ?? null + const params = deserializeValue(msg.params, getStream) as unknown[] + + // Execute the RPC method + const result = await executeRpcMethod(msg.method, params, env, ctx) + + // Serialize result (may produce streams) + const { value: serializedResult, streams } = await serializeValue(result) + + // Register outgoing streams + for (const streamRef of streams) { + activeStreams.set(streamRef.sid, { + reader: streamRef.stream.getReader(), + seq: 0 + }) + } + + // Send success response + const response: RpcOk = { + t: 'rpc.ok', + id: msg.id, + result: serializedResult + } + ws.send(stringifyJsonMsg(response)) + } catch (error) { + // Send error response + const response: RpcErr = { + t: 'rpc.err', + id: msg.id, + error: { + code: (error as any).code ?? 'INTERNAL_ERROR', + message: error instanceof Error ? error.message : String(error) + } + } + ws.send(stringifyJsonMsg(response)) + } +} + +// ----------------------------------------------------------------------------- +// RPC Method Execution +// ----------------------------------------------------------------------------- + +export async function executeRpcMethod( + method: string, + params: unknown[], + env: GatewayEnv, + ctx: ExecutionContext +): Promise { + // Parse method: "binding.operation" or "binding.sub.operation" + const parts = method.split('.') + if (parts.length < 2) { + throw new Error(`Invalid method format: ${method}`) + } + + const bindingName = parts[0] + const operation = parts.slice(1).join('.') + const binding = env[bindingName] + + if (!binding) { + throw new Error(`Binding not found: ${bindingName}`) + } + + // Strict namespacing โ€” bare verbs (e.g. `get`, `put`) and the older + // `stmt.*` / `stub.*` sub-prefixes are no longer accepted. All operations + // must be prefixed with a binding kind: `kv.`, `r2.`, `d1.`, `do.`, + // `service.`, `queue.`, `email.`, `ai.`, or `var.`. + const isNamespaced = + operation.startsWith('kv.') || + operation.startsWith('r2.') || + operation.startsWith('d1.') || + operation.startsWith('do.') || + operation.startsWith('service.') || + operation.startsWith('queue.') || + operation.startsWith('email.') || + operation.startsWith('ai.') || + operation.startsWith('workflow.') || + operation.startsWith('var.') + if (!isNamespaced) { + throw createUnsupportedBridgeOperationError(bindingName, operation) + } + + // Handle different binding types (namespaced operations) + switch (operation) { + // KV Namespace + case 'kv.get': + return (binding as KVNamespace).get(params[0] as string, params[1] as any) + case 'kv.put': + return (binding as KVNamespace).put( + params[0] as string, + params[1] as any, + params[2] as any + ) + case 'kv.delete': + return (binding as KVNamespace).delete(params[0] as string) + case 'kv.list': + return (binding as KVNamespace).list(params[0] as any) + case 'kv.getWithMetadata': + return (binding as KVNamespace).getWithMetadata(params[0] as string, params[1] as any) + + // R2 Bucket + case 'r2.head': + return serializeR2Object(await (binding as R2Bucket).head(params[0] as string)) + case 'r2.get': + return serializeR2ObjectBody(await (binding as R2Bucket).get(params[0] as string, params[1] as any)) + case 'r2.put': + return (binding as R2Bucket).put( + params[0] as string, + params[1] as any, + params[2] as any + ) + case 'r2.delete': + return (binding as R2Bucket).delete(params[0] as any) + case 'r2.list': + return (binding as R2Bucket).list(params[0] as any) + + // D1 Database + case 'd1.prepare': + return serializeD1Statement((binding as D1Database).prepare(params[0] as string)) + case 'd1.batch': + return (binding as D1Database).batch(params[0] as any) + case 'd1.exec': + return (binding as D1Database).exec(params[0] as string) + case 'd1.dump': + return (binding as D1Database).dump() + + // D1 Statement operations (from prepared statement) + case 'd1.stmt.bind': + // Statement binding handled specially + return { __type: 'D1Statement', sql: params[0], bindings: params.slice(1) } + case 'd1.stmt.first': + return executeD1Statement(binding as D1Database, params[0] as string, params.slice(1), 'first', params[params.length - 1]) + case 'd1.stmt.all': + return executeD1Statement(binding as D1Database, params[0] as string, params.slice(1), 'all') + case 'd1.stmt.run': + return executeD1Statement(binding as D1Database, params[0] as string, params.slice(1), 'run') + case 'd1.stmt.raw': + return executeD1Statement(binding as D1Database, params[0] as string, params.slice(1), 'raw', params[params.length - 1]) + + // Durable Objects + case 'do.idFromName': + return serializeDOId((binding as DurableObjectNamespace).idFromName(params[0] as string)) + case 'do.idFromString': + return serializeDOId((binding as DurableObjectNamespace).idFromString(params[0] as string)) + case 'do.newUniqueId': + return serializeDOId((binding as DurableObjectNamespace).newUniqueId(params[0] as any)) + case 'do.get': { + const doId = deserializeDOId(params[0] as any, binding as DurableObjectNamespace) + // Instantiate stub to validate id; we return a DOStub reference for the client + ; (binding as DurableObjectNamespace).get(doId) + return { __type: 'DOStub', binding: bindingName, id: params[0] } + } + case 'do.fetch': + return executeDoFetch(env, params[0] as string, params[1] as any, params[2] as any) + case 'do.rpc': + // DO RPC: Call a method on the Durable Object stub + // params = [bindingName, serializedId, methodName, methodArgs] + return executeDoRpc(env, params[0] as string, params[1] as any, params[2] as string, params[3] as unknown[]) + + // Service Bindings + case 'service.fetch': + return executeServiceFetch(bindingName, binding, params[0] as SerializedRequest) + case 'service.rpc': + return executeServiceRpc(bindingName, binding, params[0], params[1]) + + // Email + case 'email.send': + return executeSendEmail(binding as SendEmail, params[0]) + + // Workflows + case 'workflow.create': + return serializeWorkflowInstance(await (binding as Workflow).create(params[0] as any)) + case 'workflow.get': + return serializeWorkflowInstance(await (binding as Workflow).get(params[0] as string)) + case 'workflow.status': + return (await (binding as Workflow).get(params[0] as string)).status() + case 'workflow.pause': + return (await (binding as Workflow).get(params[0] as string)).pause() + case 'workflow.resume': + return (await (binding as Workflow).get(params[0] as string)).resume() + case 'workflow.terminate': + return (await (binding as Workflow).get(params[0] as string)).terminate() + case 'workflow.restart': + return (await (binding as Workflow).get(params[0] as string)).restart() + case 'workflow.sendEvent': + return (await (binding as Workflow).get(params[0] as string)).sendEvent(params[1] as any) + + // Queue + case 'queue.send': + return (binding as Queue).send(params[0], params[1] as any) + case 'queue.sendBatch': + return (binding as Queue).sendBatch(params[0] as any, params[1] as any) + + // AI (if available) + case 'ai.run': + if (typeof (binding as any).run !== 'function') { + throw new Error(`Binding ${bindingName} does not support run(): ${method}`) + } + return (binding as any).run(params[0], params[1]) + + default: + throw new Error(`Unknown operation: ${method}`) + } +} + +function createUnsupportedBridgeOperationError(bindingName: string, operation: string): Error { + const base = + `[devflare][bridge] Unsupported bridge operation '${operation}' for binding '${bindingName}'.` + + if (operation === 'fetch') { + return new Error( + `${base} Devflare could not dispatch fetch() for this binding through the local bridge. ` + + `Expected Cloudflare API: env.${bindingName}.fetch(request). ` + + 'If this came from SvelteKit platform.env, make sure the binding is declared as a service binding; ' + + 'this is a Devflare local bridge issue when service bindings fall back to a bare fetch operation.' + ) + } + + if (operation === 'toString') { + return new Error( + `${base} A platform.env value was coerced to a string through the bridge. ` + + 'For SvelteKit local dev, declared vars should be plain string values and missing env names should read as undefined.' + ) + } + + return new Error( + `${base} Bare verbs and the legacy \`stmt.*\` / \`stub.*\` sub-prefixes are not supported; ` + + 'use the namespaced form (e.g. `kv.get`, `r2.put`, `d1.stmt.first`, `do.fetch`, `service.fetch`).' + ) +} + +async function executeServiceFetch( + bindingName: string, + binding: unknown, + requestSerialized: SerializedRequest +): Promise { + if (!binding || typeof (binding as { fetch?: unknown }).fetch !== 'function') { + throw new Error(`Service binding ${bindingName} does not support fetch()`) + } + + const request = deserializeRequest(requestSerialized) + return (binding as Fetcher).fetch(request) +} + +async function executeServiceRpc( + bindingName: string, + binding: unknown, + methodName: unknown, + methodArgs: unknown +): Promise { + if (typeof methodName !== 'string') { + throw new Error(`Service binding ${bindingName} RPC method name must be a string`) + } + + const args = Array.isArray(methodArgs) ? methodArgs : [] + const method = (binding as Record | null | undefined)?.[methodName] + if (typeof method !== 'function') { + throw new Error(`Service binding ${bindingName} does not support ${methodName}()`) + } + + return method.apply(binding, args) +} + +async function executeSendEmail(binding: SendEmail, message: unknown): Promise { + return binding.send(normalizeSendEmailMessage(message)) +} + +function serializeWorkflowInstance(instance: WorkflowInstance): unknown { + return { + __type: 'WorkflowInstance', + id: instance.id + } +} + +// ----------------------------------------------------------------------------- +// R2 Helpers +// ----------------------------------------------------------------------------- + +/** Serialize R2Object metadata (no body) */ +function serializeR2Object(obj: R2Object | null): unknown { + if (!obj) return null + return { + __type: 'R2Object', + key: obj.key, + version: obj.version, + size: obj.size, + etag: obj.etag, + httpEtag: obj.httpEtag, + checksums: obj.checksums, + uploaded: obj.uploaded?.toISOString(), + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + range: obj.range, + storageClass: obj.storageClass + } +} + +/** Serialize R2ObjectBody (includes body data) */ +async function serializeR2ObjectBody(obj: R2ObjectBody | R2Object | null): Promise { + if (!obj) return null + + // Check if it's an R2ObjectBody (has body/arrayBuffer) + const hasBody = 'body' in obj || 'arrayBuffer' in obj + + if (!hasBody) { + // It's just R2Object (metadata only) + return serializeR2Object(obj as R2Object) + } + + // It's R2ObjectBody - read the body content + const body = obj as R2ObjectBody + const arrayBuffer = await body.arrayBuffer() + const bodyData = base64Encode(new Uint8Array(arrayBuffer)) + + return { + __type: 'R2ObjectBody', + key: body.key, + version: body.version, + size: body.size, + etag: body.etag, + httpEtag: body.httpEtag, + checksums: body.checksums, + uploaded: body.uploaded?.toISOString(), + httpMetadata: body.httpMetadata, + customMetadata: body.customMetadata, + range: body.range, + storageClass: body.storageClass, + // Body data as base64 + bodyData + } +} + +// ----------------------------------------------------------------------------- +// D1 Helpers +// ----------------------------------------------------------------------------- + +function serializeD1Statement(stmt: D1PreparedStatement): unknown { + return { __type: 'D1Statement' } +} + +async function executeD1Statement( + db: D1Database, + sql: string, + bindings: unknown[], + mode: 'first' | 'all' | 'run' | 'raw', + extra?: unknown +): Promise { + let stmt = db.prepare(sql) + if (bindings.length > 0) { + stmt = stmt.bind(...bindings) + } + + switch (mode) { + case 'first': + return typeof extra === 'string' ? stmt.first(extra) : stmt.first() + case 'all': + return stmt.all() + case 'run': + return stmt.run() + case 'raw': + return stmt.raw(extra as any) + } +} + +// ----------------------------------------------------------------------------- +// Durable Object Helpers +// ----------------------------------------------------------------------------- + +async function executeDoFetch( + env: GatewayEnv, + bindingName: string, + idSerialized: any, + requestSerialized: any +): Promise { + const binding = env[bindingName] as DurableObjectNamespace + const id = deserializeDOId(idSerialized, binding) + const stub = binding.get(id) + + // Reconstruct request + const bodyBytes = requestSerialized.body?.type === 'bytes' + ? base64Decode(requestSerialized.body.data) + : undefined + // Convert Uint8Array to ArrayBuffer for BodyInit compatibility + const bodyBuffer = bodyBytes + ? bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer + : undefined + const request = new Request(requestSerialized.url, { + method: requestSerialized.method, + headers: requestSerialized.headers, + body: bodyBuffer + }) + + return stub.fetch(request) +} + +/** + * Execute an RPC method on a Durable Object stub + * + * This uses the DO's internal `_rpc` endpoint convention to call methods. + * The DO class must expose an RPC handler via fetch() that routes to methods. + */ +async function executeDoRpc( + env: GatewayEnv, + bindingName: string, + idSerialized: any, + methodName: string, + args: unknown[] +): Promise { + const binding = env[bindingName] as DurableObjectNamespace + const id = deserializeDOId(idSerialized, binding) + const stub = binding.get(id) + + // Call the DO's RPC endpoint + // Convention: POST to /_rpc with { method, params } + const response = await stub.fetch(new Request('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: methodName, params: args }) + })) + + const result = await response.json() as { ok: boolean; result?: unknown; error?: { message: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'DO RPC failed') + } + return result.result +} + +// ----------------------------------------------------------------------------- +// Stream Handlers +// ----------------------------------------------------------------------------- + +async function handleStreamPull( + msg: StreamPull, + ws: WebSocket, + activeStreams: Map +): Promise { + const stream = activeStreams.get(msg.sid) + if (!stream) return + + let sent = 0 + const maxBytes = msg.creditBytes + + try { + while (sent < maxBytes) { + const { done, value } = await stream.reader.read() + + if (done) { + ws.send(stringifyJsonMsg({ t: 'stream.end', sid: msg.sid })) + activeStreams.delete(msg.sid) + break + } + + if (value) { + const frame = encodeBinaryFrame( + BinaryKind.StreamChunk, + msg.sid, + stream.seq++, + 0, + value + ) + ws.send(frame) + sent += value.byteLength + } + } + } catch (error) { + ws.send(stringifyJsonMsg({ + t: 'stream.abort', + sid: msg.sid, + error: String(error) + })) + activeStreams.delete(msg.sid) + } +} + +function handleStreamOpen( + msg: StreamOpen, + incomingStreams: Map; stream: ReadableStream }> +): void { + let controller!: ReadableStreamDefaultController + const stream = new ReadableStream({ + start(c) { + controller = c + } + }) + incomingStreams.set(msg.sid, { controller, stream }) +} + +function handleStreamEnd( + msg: { sid: number }, + incomingStreams: Map; stream: ReadableStream }> +): void { + const stream = incomingStreams.get(msg.sid) + if (stream) { + stream.controller.close() + incomingStreams.delete(msg.sid) + } +} + +function handleStreamAbort( + msg: { sid: number; error?: string }, + incomingStreams: Map; stream: ReadableStream }> +): void { + const stream = incomingStreams.get(msg.sid) + if (stream) { + stream.controller.error(new Error(msg.error ?? 'Stream aborted')) + incomingStreams.delete(msg.sid) + } +} + +// ----------------------------------------------------------------------------- +// Binary Message Handler +// ----------------------------------------------------------------------------- + +function handleBinaryMessage( + frame: Uint8Array, + ws: WebSocket, + wsProxies: Map, + incomingStreams: Map; stream: ReadableStream }> +): void { + const decoded = decodeBinaryFrame(frame) + + switch (decoded.kind) { + case BinaryKind.StreamChunk: + // Incoming stream chunk from client + const stream = incomingStreams.get(decoded.id) + if (stream) { + stream.controller.enqueue(decoded.payload) + } + break + case BinaryKind.WsData: + // Forward to DO WebSocket + const proxy = wsProxies.get(decoded.id) + if (proxy) { + const isText = (decoded.flags & BinaryFlags.TEXT) !== 0 + if (isText) { + proxy.doWs.send(new TextDecoder().decode(decoded.payload)) + } else { + proxy.doWs.send(decoded.payload) + } + } + break + } +} + +// ----------------------------------------------------------------------------- +// WebSocket Proxy Handlers +// ----------------------------------------------------------------------------- + +async function handleWsOpen( + msg: WsOpen, + ws: WebSocket, + env: GatewayEnv, + wsProxies: Map +): Promise { + try { + const binding = env[msg.target.binding] as DurableObjectNamespace + const id = binding.idFromString(msg.target.id) + const stub = binding.get(id) + + // Create request for DO WebSocket upgrade + const headers = new Headers(msg.target.headers ?? []) + headers.set('Upgrade', 'websocket') + + const request = new Request(msg.target.url, { + method: 'GET', + headers + }) + + const response = await stub.fetch(request) + const doWs = response.webSocket + + if (!doWs) { + ws.send(stringifyJsonMsg({ + t: 'rpc.err', + id: `ws_${msg.wid}`, + error: { code: 'WS_UPGRADE_FAILED', message: 'DO did not return WebSocket' } + })) + return + } + + doWs.accept() + + // Set up proxy + const proxy: ActiveWsProxy = { + doWs, + clientWid: msg.wid + } + wsProxies.set(msg.wid, proxy) + + // Forward messages from DO to client + doWs.addEventListener('message', (event) => { + const isText = typeof event.data === 'string' + const payload = isText + ? new TextEncoder().encode(event.data) + : new Uint8Array(event.data as ArrayBuffer) + const flags = isText ? BinaryFlags.TEXT : 0 + + const frame = encodeBinaryFrame(BinaryKind.WsData, msg.wid, 0, flags, payload) + ws.send(frame) + }) + + doWs.addEventListener('close', (event) => { + ws.send(stringifyJsonMsg({ + t: 'ws.close', + wid: msg.wid, + code: event.code, + reason: event.reason + })) + wsProxies.delete(msg.wid) + }) + + // Send opened confirmation + ws.send(stringifyJsonMsg({ t: 'ws.opened', wid: msg.wid })) + } catch (error) { + ws.send(stringifyJsonMsg({ + t: 'rpc.err', + id: `ws_${msg.wid}`, + error: { + code: 'WS_OPEN_FAILED', + message: error instanceof Error ? error.message : String(error) + } + })) + } +} + +function handleWsClose( + msg: WsClose, + wsProxies: Map +): void { + const proxy = wsProxies.get(msg.wid) + if (proxy) { + proxy.doWs.close(msg.code, msg.reason) + wsProxies.delete(msg.wid) + } +} + +// ----------------------------------------------------------------------------- +// HTTP Transfer Handler (for large files) +// ----------------------------------------------------------------------------- + +async function handleHttpTransfer( + request: Request, + env: GatewayEnv, + url: URL +): Promise { + // URL format: /_devflare/transfer/{id} + const transferId = decodeURIComponent(url.pathname.split('/').pop() ?? '') + + // For uploads, the body is streamed directly + if (request.method === 'PUT' || request.method === 'POST') { + // Transfer ID contains binding info: {binding}:{key} + const [binding, ...keyParts] = transferId.split(':') + const key = keyParts.join(':') + + const bucket = env[binding] as R2Bucket + if (!bucket) { + return new Response('Binding not found', { status: 404 }) + } + + const result = await bucket.put(key, request.body) + return new Response(JSON.stringify(serializeR2Object(result)), { + headers: { 'Content-Type': 'application/json' } + }) + } + + // For downloads + if (request.method === 'GET') { + const [binding, ...keyParts] = transferId.split(':') + const key = keyParts.join(':') + + const bucket = env[binding] as R2Bucket + if (!bucket) { + return new Response('Binding not found', { status: 404 }) + } + + const object = await bucket.get(key) + if (!object) { + return new Response('Object not found', { status: 404 }) + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', + 'Content-Length': String(object.size) + } + }) + } + + return new Response('Method not allowed', { status: 405 }) +} diff --git a/packages/devflare/src/bridge/v2/body-streams.ts b/packages/devflare/src/bridge/v2/body-streams.ts new file mode 100644 index 0000000..263217a --- /dev/null +++ b/packages/devflare/src/bridge/v2/body-streams.ts @@ -0,0 +1,244 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Body Stream Reader / Writer +// ============================================================================= +// +// Turns a Web `ReadableStream` body into a sequence of +// `body.open` + `BodyChunk` frames + `body.end` (writer side), and the inverse +// (reader side) โ€” collecting incoming chunk frames into a `ReadableStream` +// that callers can attach to a `Request` / `Response`. +// +// SCOPE: pure transport concerns. The codec module owns frame I/O and stream +// id allocation. This module owns chunking, FIN/ABORT handling, and the +// reader-side queue. +// ============================================================================= + +import { + TransportV2BinaryFlags, + TransportV2BinaryKind, + encodeTransportV2BinaryFrame, + stringifyTransportV2ControlMsg, + transportV2IsAbort, + transportV2IsFin +} from './frames' +import type { + TransportV2BodyAbort, + TransportV2BodyEnd, + TransportV2BodyKind, + TransportV2BodyOpen, + TransportV2DecodedBinaryFrame +} from './frames' + +/** Default maximum payload size per body chunk frame (256 KiB). */ +export const TRANSPORT_V2_DEFAULT_BODY_CHUNK_SIZE = 256 * 1024 + +export interface TransportV2BodyWriterOptions { + /** Maximum payload bytes per `BodyChunk` frame. Defaults to 256 KiB. */ + chunkSize?: number + /** Optional content-length hint to include in `body.open`. */ + contentLength?: number + /** Optional content-type hint to include in `body.open`. */ + contentType?: string +} + +export interface TransportV2BodyWriterIo { + sendText(message: string): void + sendBinary(frame: Uint8Array): void +} + +/** + * Stream a `ReadableStream` over the v2 wire as `body.open` + + * `BodyChunk` frames + `body.end`. Returns once the source stream is + * exhausted or has been cancelled by the writer. + * + * The returned promise rejects if either the source stream errors or one of + * the I/O calls throws; in both cases a `body.abort` frame is emitted before + * the promise rejects so the reader side can release resources. + */ +export async function writeTransportV2Body( + source: ReadableStream, + options: { + bid: number + kind: TransportV2BodyKind + rpcId: string + io: TransportV2BodyWriterIo + writerOptions?: TransportV2BodyWriterOptions + } +): Promise { + const { bid, kind, rpcId, io, writerOptions } = options + const chunkSize = writerOptions?.chunkSize ?? TRANSPORT_V2_DEFAULT_BODY_CHUNK_SIZE + if (chunkSize <= 0) { + throw new RangeError(`v2 body writer chunk size must be > 0 (got ${chunkSize})`) + } + + const open: TransportV2BodyOpen = { + t: 'body.open', + bid, + kind, + rpcId, + ...(writerOptions?.contentType !== undefined ? { contentType: writerOptions.contentType } : {}), + ...(writerOptions?.contentLength !== undefined ? { contentLength: writerOptions.contentLength } : {}) + } + io.sendText(stringifyTransportV2ControlMsg(open)) + + const reader = source.getReader() + let seq = 0 + + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + if (value === undefined) continue + + let offset = 0 + while (offset < value.byteLength) { + const end = Math.min(offset + chunkSize, value.byteLength) + const slice = value.subarray(offset, end) + io.sendBinary( + encodeTransportV2BinaryFrame( + TransportV2BinaryKind.BodyChunk, + bid, + seq, + 0, + slice + ) + ) + seq += 1 + offset = end + } + } + + // Final FIN-only frame so the reader's queue closes deterministically + // even when the source stream produced zero bytes total. + io.sendBinary( + encodeTransportV2BinaryFrame( + TransportV2BinaryKind.BodyChunk, + bid, + seq, + TransportV2BinaryFlags.FIN, + new Uint8Array(0) + ) + ) + const end: TransportV2BodyEnd = { t: 'body.end', bid, kind } + io.sendText(stringifyTransportV2ControlMsg(end)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const abort: TransportV2BodyAbort = { t: 'body.abort', bid, kind, error: message } + try { + io.sendBinary( + encodeTransportV2BinaryFrame( + TransportV2BinaryKind.BodyChunk, + bid, + seq, + TransportV2BinaryFlags.ABORT, + new Uint8Array(0) + ) + ) + io.sendText(stringifyTransportV2ControlMsg(abort)) + } catch { + // Swallow secondary I/O errors โ€” the original failure is what callers care about. + } + throw error + } finally { + reader.releaseLock() + } +} + +interface BodyReaderState { + controller: ReadableStreamDefaultController | null + stream: ReadableStream + closed: boolean +} + +/** + * Tracks in-flight v2 body streams on the reader side. The codec routes + * incoming `body.open` / `body.end` / `body.abort` control messages and + * `BodyChunk` binary frames here; consumers of v2 RPC results obtain a + * `ReadableStream` to attach to a `Request` / `Response`. + */ +export class TransportV2BodyReaderRegistry { + #streams = new Map() + + /** Register a new body stream and return the reader-side `ReadableStream`. Idempotent: returns the existing stream if `bid` is already registered. */ + getOrOpen(bid: number): ReadableStream { + const existing = this.#streams.get(bid) + if (existing !== undefined) return existing.stream + return this.open(bid) + } + + /** Register a new body stream and return the reader-side `ReadableStream`. Throws if `bid` is already registered. */ + open(bid: number): ReadableStream { + if (this.#streams.has(bid)) { + throw new Error(`v2 body reader already registered for bid ${bid}`) + } + const state: BodyReaderState = { + controller: null, + stream: null as unknown as ReadableStream, + closed: false + } + state.stream = new ReadableStream({ + start: (controller) => { + state.controller = controller + }, + cancel: () => { + state.closed = true + this.#streams.delete(bid) + } + }) + this.#streams.set(bid, state) + return state.stream + } + + /** Push a decoded BodyChunk frame to the matching reader. */ + pushChunk(frame: TransportV2DecodedBinaryFrame): void { + if (frame.kind !== TransportV2BinaryKind.BodyChunk) { + throw new Error(`v2 body reader received non-BodyChunk frame (kind=${frame.kind})`) + } + const state = this.#streams.get(frame.id) + if (state === undefined || state.closed) return + + const isFin = transportV2IsFin(frame.flags) + const isAbort = transportV2IsAbort(frame.flags) + + if (isAbort) { + state.closed = true + state.controller?.error(new Error(`v2 body stream ${frame.id} aborted by writer`)) + this.#streams.delete(frame.id) + return + } + + if (frame.payload.byteLength > 0) { + // Copy the payload because the underlying buffer may be reused by + // the codec for subsequent frames. + state.controller?.enqueue(new Uint8Array(frame.payload)) + } + + if (isFin) { + state.closed = true + state.controller?.close() + this.#streams.delete(frame.id) + } + } + + /** Handle a `body.end` control message (writer signalled clean end). */ + end(bid: number): void { + const state = this.#streams.get(bid) + if (state === undefined || state.closed) return + state.closed = true + state.controller?.close() + this.#streams.delete(bid) + } + + /** Handle a `body.abort` control message. */ + abort(bid: number, reason?: string): void { + const state = this.#streams.get(bid) + if (state === undefined || state.closed) return + state.closed = true + state.controller?.error(new Error(reason ?? `v2 body stream ${bid} aborted`)) + this.#streams.delete(bid) + } + + /** Number of currently open reader-side body streams (for tests/diagnostics). */ + get size(): number { + return this.#streams.size + } +} diff --git a/packages/devflare/src/bridge/v2/codec.ts b/packages/devflare/src/bridge/v2/codec.ts new file mode 100644 index 0000000..1a98360 --- /dev/null +++ b/packages/devflare/src/bridge/v2/codec.ts @@ -0,0 +1,432 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Codec (Handshake + Frame Demultiplexer + RPC) +// ============================================================================= +// +// `TransportV2Codec` attaches to a `WebSocketLike` and provides a typed v2 +// API on top of it: handshake, RPC call/response, body stream registration. +// +// Frame routing: +// - Text frames โ†’ JSON-parsed v2 control messages, dispatched per `t` field. +// Frames with `t` not in the v2 vocabulary are forwarded to the optional +// `onUnknownControl` hook so that callers wiring v2 alongside v1 can keep +// handling the v1 vocabulary themselves during the dual-mode period. +// - Binary frames โ†’ decoded v2 binary frames. `BodyChunk` frames are +// forwarded to the body-stream registry; other kinds are forwarded to +// `onUnknownBinary`. +// ============================================================================= + +import { TransportV2BodyReaderRegistry } from './body-streams' +import { + TRANSPORT_V2_PROTOCOL_VERSION, + TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE, + TransportV2BinaryKind, + decodeTransportV2BinaryFrame, + negotiateTransportV2Capabilities, + parseTransportV2ControlMsg, + stringifyTransportV2ControlMsg +} from './frames' +import type { + TransportV2BodyKind, + TransportV2ControlMsg, + TransportV2DecodedBinaryFrame, + TransportV2Hello, + TransportV2Welcome +} from './frames' +import type { WebSocketLike, WebSocketLikeMessageEvent } from './transport' + +export interface TransportV2HandshakeOk { + protocolVersion: typeof TRANSPORT_V2_PROTOCOL_VERSION + capabilities: string[] +} + +export interface TransportV2RpcCall { + t: 'rpc.call' + id: string + method: string + params: unknown[] +} + +export interface TransportV2RpcOk { + t: 'rpc.ok' + id: string + result: unknown +} + +export interface TransportV2RpcErr { + t: 'rpc.err' + id: string + error: { code: string; message: string; details?: unknown } +} + +/** + * Out-of-band structured error frame (B5-frame). See + * `src/bridge/v2/wire.ts#WireError` for the canonical vocabulary spec. + */ +export interface TransportV2WireError { + t: 'error' + scope: 'transport' | 'rpc' | 'stream' | 'ws' + error: { code: string; message: string; details?: unknown } + refId?: string | number +} + +export type TransportV2RpcMsg = TransportV2RpcCall | TransportV2RpcOk | TransportV2RpcErr + +export interface TransportV2CodecOptions { + /** Capability strings this side supports. Both halves of the bridge advertise these in `hello`/`welcome`. */ + capabilities?: readonly string[] + /** Called when an RPC call arrives (server-side handler). */ + onRpcCall?: (call: TransportV2RpcCall) => void + /** Called when a non-v2 control message arrives. Used during the dual-mode period to keep handling v1 messages. */ + onUnknownControl?: (data: string) => void + /** Called when a non-BodyChunk binary frame arrives. Used during dual-mode for v1 stream/ws frames. */ + onUnknownBinary?: (frame: TransportV2DecodedBinaryFrame) => void + /** + * Called when an out-of-band `error` frame (B5-frame) arrives โ€” i.e. + * a structured failure that is not pinned to a single RPC call id. + * Useful for surfacing transport/stream/ws errors that previously fell + * through silent `catch {}` blocks. + */ + onWireError?: (err: TransportV2WireError) => void +} + +interface PendingRpc { + resolve: (result: unknown) => void + reject: (error: Error) => void +} + +/** + * v2 codec attached to a single `WebSocketLike`. Owns handshake state, the + * frame demultiplexer, RPC pending-call table, and the body-stream registry. + */ +export class TransportV2Codec { + readonly bodyReaders = new TransportV2BodyReaderRegistry() + #socket: WebSocketLike + #capabilities: readonly string[] + #options: TransportV2CodecOptions + #handshakeResolver: { resolve: (value: TransportV2HandshakeOk) => void; reject: (error: Error) => void } | null = null + #handshakePromise: Promise + #sentHello = false + #receivedHello = false + #receivedWelcome = false + #negotiated: TransportV2HandshakeOk | null = null + #pendingRpc = new Map() + #nextBid = 1 + #nextRpcId = 1 + #closed = false + + constructor(socket: WebSocketLike, options: TransportV2CodecOptions = {}) { + this.#socket = socket + this.#capabilities = options.capabilities ?? [] + this.#options = options + + this.#handshakePromise = new Promise((resolve, reject) => { + this.#handshakeResolver = { resolve, reject } + }) + + socket.onmessage = (event) => this.#onMessage(event) + socket.onclose = () => this.#onClose() + socket.onerror = (event) => this.#onError(event.error) + } + + /** Allocate a fresh body id (writer side). */ + allocateBid(): number { + return this.#nextBid++ + } + + /** Allocate a fresh RPC id (caller side). */ + allocateRpcId(): string { + return `v2_rpc_${this.#nextRpcId++}` + } + + /** Send the `hello` frame. Called once by the side that initiates the handshake. */ + sendHello(): void { + if (this.#sentHello) return + this.#sentHello = true + const hello: TransportV2Hello = { + t: 'hello', + protocolVersion: TRANSPORT_V2_PROTOCOL_VERSION, + capabilities: [...this.#capabilities] + } + this.#socket.send(stringifyTransportV2ControlMsg(hello)) + } + + /** Promise that resolves once the handshake completes. */ + get handshake(): Promise { + return this.#handshakePromise + } + + /** Negotiated capabilities once the handshake completes. */ + get negotiated(): TransportV2HandshakeOk | null { + return this.#negotiated + } + + sendText(message: string): void { + if (this.#closed) { + throw new Error('cannot send on a closed v2 codec') + } + this.#socket.send(message) + } + + sendBinary(frame: Uint8Array): void { + if (this.#closed) { + throw new Error('cannot send on a closed v2 codec') + } + this.#socket.send(frame) + } + + /** Send a typed RPC call and resolve with the peer's `rpc.ok` result. */ + call(method: string, params: unknown[] = []): Promise { + const id = this.allocateRpcId() + const call: TransportV2RpcCall = { t: 'rpc.call', id, method, params } + const promise = new Promise((resolve, reject) => { + this.#pendingRpc.set(id, { resolve, reject }) + }) + this.sendText(JSON.stringify(call)) + return promise + } + + /** Send an `rpc.ok` reply for a previously-received call. */ + respondOk(id: string, result: unknown): void { + const reply: TransportV2RpcOk = { t: 'rpc.ok', id, result } + this.sendText(JSON.stringify(reply)) + } + + /** Send an `rpc.err` reply for a previously-received call. */ + respondErr(id: string, error: { code: string; message: string; details?: unknown }): void { + const reply: TransportV2RpcErr = { t: 'rpc.err', id, error } + this.sendText(JSON.stringify(reply)) + } + + /** + * Send an out-of-band structured `error` frame (B5-frame). Use for + * failures that are not scoped to a single RPC call id โ€” e.g. malformed + * incoming frames, stream/ws aborts that need a typed cause, or gateway + * bookkeeping errors. Replaces silent `catch {}` fallthroughs. + */ + sendWireError(err: Omit): void { + const frame: TransportV2WireError = { t: 'error', ...err } + this.sendText(JSON.stringify(frame)) + } + + /** Register a reader-side body stream. Returns the `ReadableStream`. Idempotent across the codec's `body.open` arrival. */ + openBodyReader(bid: number): ReadableStream { + return this.bodyReaders.getOrOpen(bid) + } + + /** Replace the `onRpcCall` handler after construction. Useful for one-shot tests and for higher RPC layers that build the handler lazily. */ + setRpcCallHandler(handler: (call: TransportV2RpcCall) => void): void { + this.#options = { ...this.#options, onRpcCall: handler } + } + + /** Close the underlying transport with an optional code/reason. */ + close(code?: number, reason?: string): void { + if (this.#closed) return + this.#closed = true + this.#failPending(new Error('v2 transport closed')) + this.#socket.close(code, reason) + } + + get isClosed(): boolean { + return this.#closed + } + + // ------------------------------------------------------------------------- + // Internal โ€” message routing + // ------------------------------------------------------------------------- + + #onMessage(event: WebSocketLikeMessageEvent): void { + const { data } = event + if (typeof data === 'string') { + this.#onText(data) + } else if (data instanceof Uint8Array) { + this.#onBinary(data) + } else if (data instanceof ArrayBuffer) { + this.#onBinary(new Uint8Array(data)) + } + } + + #onText(data: string): void { + // Try to parse as a v2 control message first; fall back to the + // dual-mode hook on unknown types. + let msg: TransportV2ControlMsg + try { + msg = parseTransportV2ControlMsg(data) + } catch { + // Probe for an RPC message (which is part of v2's vocabulary even + // though it shares its `t` field shape with v1). + const rpc = tryParseRpcMsg(data) + if (rpc !== null) { + this.#onRpcMessage(rpc) + return + } + // Probe for an out-of-band wire error frame (B5-frame). Lives at + // the same parser tier as RPC because it is not part of the v2 + // control vocabulary in `frames.ts` but is part of the wider + // wire vocabulary in `wire.ts`. + const wireErr = tryParseWireError(data) + if (wireErr !== null) { + this.#options.onWireError?.(wireErr) + return + } + this.#options.onUnknownControl?.(data) + return + } + + switch (msg.t) { + case 'hello': + this.#onHello(msg) + break + case 'welcome': + this.#onWelcome(msg) + break + case 'body.open': + // Reader-side allocation is the responsibility of the higher + // RPC layer; the codec uses idempotent `getOrOpen` so the + // writer's first frame is not dropped if it arrives before the + // consumer attaches. + this.bodyReaders.getOrOpen(msg.bid) + break + case 'body.end': + this.bodyReaders.end(msg.bid) + break + case 'body.abort': + this.bodyReaders.abort(msg.bid, msg.error) + break + } + } + + #onBinary(data: Uint8Array): void { + let frame: TransportV2DecodedBinaryFrame + try { + frame = decodeTransportV2BinaryFrame(data) + } catch { + // Malformed v2 binary frame โ€” give the dual-mode hook a chance to + // see the raw bytes; otherwise drop it. + return + } + if (frame.kind === TransportV2BinaryKind.BodyChunk) { + this.bodyReaders.pushChunk(frame) + return + } + this.#options.onUnknownBinary?.(frame) + } + + #onRpcMessage(msg: TransportV2RpcMsg): void { + switch (msg.t) { + case 'rpc.call': + this.#options.onRpcCall?.(msg) + break + case 'rpc.ok': { + const pending = this.#pendingRpc.get(msg.id) + if (pending !== undefined) { + this.#pendingRpc.delete(msg.id) + pending.resolve(msg.result) + } + break + } + case 'rpc.err': { + const pending = this.#pendingRpc.get(msg.id) + if (pending !== undefined) { + this.#pendingRpc.delete(msg.id) + const err = new Error(msg.error.message) + Object.assign(err, { code: msg.error.code, details: msg.error.details }) + pending.reject(err) + } + break + } + } + } + + #onHello(msg: TransportV2Hello): void { + if (this.#receivedHello) { + this.close(TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE, 'duplicate hello') + return + } + this.#receivedHello = true + + const negotiated = negotiateTransportV2Capabilities(this.#capabilities, msg.capabilities) + const welcome: TransportV2Welcome = { + t: 'welcome', + protocolVersion: TRANSPORT_V2_PROTOCOL_VERSION, + capabilities: negotiated + } + this.#socket.send(stringifyTransportV2ControlMsg(welcome)) + + // Server-side completes the handshake on receipt of `hello`. + this.#completeHandshake({ protocolVersion: TRANSPORT_V2_PROTOCOL_VERSION, capabilities: negotiated }) + } + + #onWelcome(msg: TransportV2Welcome): void { + if (this.#receivedWelcome) return + this.#receivedWelcome = true + this.#completeHandshake({ + protocolVersion: TRANSPORT_V2_PROTOCOL_VERSION, + capabilities: msg.capabilities + }) + } + + #completeHandshake(result: TransportV2HandshakeOk): void { + if (this.#negotiated !== null) return + this.#negotiated = result + this.#handshakeResolver?.resolve(result) + this.#handshakeResolver = null + } + + #onClose(): void { + if (this.#closed) { + // Codec.close() already drained pending callers; nothing more to do. + return + } + this.#closed = true + this.#failPending(new Error('v2 transport closed')) + } + + #onError(error: unknown): void { + const wrapped = error instanceof Error ? error : new Error(String(error)) + this.#failPending(wrapped) + } + + #failPending(error: Error): void { + const resolver = this.#handshakeResolver + if (resolver !== null) { + this.#handshakeResolver = null + resolver.reject(error) + } + for (const pending of this.#pendingRpc.values()) { + pending.reject(error) + } + this.#pendingRpc.clear() + } +} + +function tryParseRpcMsg(data: string): TransportV2RpcMsg | null { + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null || !('t' in parsed)) return null + const t = (parsed as { t: unknown }).t + if (t === 'rpc.call' || t === 'rpc.ok' || t === 'rpc.err') { + return parsed as TransportV2RpcMsg + } + return null +} + +function tryParseWireError(data: string): TransportV2WireError | null { + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null || !('t' in parsed)) return null + const candidate = parsed as { t: unknown; scope?: unknown; error?: unknown } + if (candidate.t !== 'error') return null + const scope = candidate.scope + if (scope !== 'transport' && scope !== 'rpc' && scope !== 'stream' && scope !== 'ws') return null + const err = candidate.error as { code?: unknown; message?: unknown } | undefined + if (!err || typeof err.code !== 'string' || typeof err.message !== 'string') return null + return parsed as TransportV2WireError +} diff --git a/packages/devflare/src/bridge/v2/control-messages.ts b/packages/devflare/src/bridge/v2/control-messages.ts new file mode 100644 index 0000000..3ab950c --- /dev/null +++ b/packages/devflare/src/bridge/v2/control-messages.ts @@ -0,0 +1,96 @@ +๏ปฟ// ============================================================================= +// Transport v2 โ€” Auxiliary control vocabulary +// ============================================================================= +// The v2 codec only owns rpc.{call,ok,err} + body.* + hello/welcome/error. +// Everything else (WS relay envelopes, fire-and-forget pub/sub events, +// HTTP transfer notifications) rides on the codec's `onUnknownControl` +// hook. This module is the single source of truth for those shapes. +// ============================================================================= + +export interface TransportV2WsOpenMsg { + t: 'ws.open' + id: string + binding: string + path: string + headers: Record + doId?: string + doName?: string +} + +export interface TransportV2WsOpenedMsg { + t: 'ws.opened' + id: string + subprotocol?: string +} + +export interface TransportV2WsOpenErrMsg { + t: 'ws.openerr' + id: string + error: { message: string; code?: string } +} + +export interface TransportV2WsTextMsg { + t: 'ws.text' + id: string + data: string +} + +export interface TransportV2WsCloseMsg { + t: 'ws.close' + id: string + code?: number + reason?: string +} + +export interface TransportV2EventMsg { + t: 'event' + channel: string + payload: unknown +} + +export interface TransportV2HttpTransferMsg { + t: 'http.transfer' + id: string + url: string + method: 'GET' | 'PUT' + headers?: Record + bytes: number +} + +export type TransportV2AuxMsg = + | TransportV2WsOpenMsg + | TransportV2WsOpenedMsg + | TransportV2WsOpenErrMsg + | TransportV2WsTextMsg + | TransportV2WsCloseMsg + | TransportV2EventMsg + | TransportV2HttpTransferMsg + +const TRANSPORT_V2_AUX_TAGS = new Set([ + 'ws.open', + 'ws.opened', + 'ws.openerr', + 'ws.text', + 'ws.close', + 'event', + 'http.transfer' +]) + +export function isTransportV2AuxMsg(value: unknown): value is TransportV2AuxMsg { + if (!value || typeof value !== 'object') return false + const tag = (value as { t?: unknown }).t + return typeof tag === 'string' && TRANSPORT_V2_AUX_TAGS.has(tag) +} + +export function parseTransportV2AuxMsg(text: string): TransportV2AuxMsg | null { + try { + const parsed = JSON.parse(text) as unknown + return isTransportV2AuxMsg(parsed) ? parsed : null + } catch { + return null + } +} + +export function stringifyTransportV2AuxMsg(msg: TransportV2AuxMsg): string { + return JSON.stringify(msg) +} diff --git a/packages/devflare/src/bridge/v2/frames.ts b/packages/devflare/src/bridge/v2/frames.ts new file mode 100644 index 0000000..b481c60 --- /dev/null +++ b/packages/devflare/src/bridge/v2/frames.ts @@ -0,0 +1,270 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Frame Vocabulary +// ============================================================================= +// +// Foundation module for the F09/F11 transport-v2 program. See +// `../TRANSPORT_V2.md` for the architecture note this implements. +// +// SCOPE: This file defines the wire vocabulary (control-plane JSON kinds, +// extended binary frame header, handshake payloads) and pure encoder/decoder +// helpers. It is intentionally NOT imported by the existing `server.ts`, +// `client.ts`, `proxy.ts`, or `miniflare.ts` modules. Wiring is deferred to +// the dual-mode phase so v1 transport behavior stays bit-identical until v2 +// reaches feature parity. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Protocol version +// ----------------------------------------------------------------------------- + +/** Wire protocol version emitted in `hello` / `welcome` handshakes. */ +export const TRANSPORT_V2_PROTOCOL_VERSION = 2 as const + +/** WebSocket close code used when v2 โ†” v1 mismatch is detected at handshake. */ +export const TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE = 4001 as const + +// ----------------------------------------------------------------------------- +// Control plane โ€” handshake +// ----------------------------------------------------------------------------- + +/** Initial handshake sent by the side that opens the WebSocket. */ +export interface TransportV2Hello { + t: 'hello' + protocolVersion: typeof TRANSPORT_V2_PROTOCOL_VERSION + capabilities: string[] +} + +/** Handshake reply with the negotiated capability intersection. */ +export interface TransportV2Welcome { + t: 'welcome' + protocolVersion: typeof TRANSPORT_V2_PROTOCOL_VERSION + capabilities: string[] +} + +// ----------------------------------------------------------------------------- +// Control plane โ€” body streams +// ----------------------------------------------------------------------------- + +/** Side that owns a body stream. `'value'` is used for free-standing + * ReadableStream parameters/results that are not tied to a Request/Response. */ +export type TransportV2BodyKind = 'request' | 'response' | 'value' + +/** Declares a streaming body for an in-flight request or response. */ +export interface TransportV2BodyOpen { + t: 'body.open' + bid: number + kind: TransportV2BodyKind + rpcId: string + contentType?: string + contentLength?: number +} + +/** Signals that a body stream has finished cleanly. */ +export interface TransportV2BodyEnd { + t: 'body.end' + bid: number + kind: TransportV2BodyKind +} + +/** Signals that a body stream was cancelled or errored. */ +export interface TransportV2BodyAbort { + t: 'body.abort' + bid: number + kind: TransportV2BodyKind + error?: string +} + +/** Union of v2-specific JSON control messages. */ +export type TransportV2ControlMsg = + | TransportV2Hello + | TransportV2Welcome + | TransportV2BodyOpen + | TransportV2BodyEnd + | TransportV2BodyAbort + +// ----------------------------------------------------------------------------- +// Data plane โ€” extended binary frame +// ----------------------------------------------------------------------------- + +/** + * Binary frame kinds for v2. Values 1 (StreamChunk) and 2 (WsData) are + * deliberately stable with v1 so a future shared decoder can route either + * version's frames. Value 3 (BodyChunk) is new in v2 for HTTP body streams. + */ +export const TransportV2BinaryKind = { + StreamChunk: 1, + WsData: 2, + BodyChunk: 3 +} as const + +export type TransportV2BinaryKind = + typeof TransportV2BinaryKind[keyof typeof TransportV2BinaryKind] + +/** Binary frame flags for v2. */ +export const TransportV2BinaryFlags = { + FIN: 0b0001, + TEXT: 0b0010, + ABORT: 0b0100 +} as const + +/** + * Binary frame header layout (10 bytes, identical to v1 so wire shape stays + * compatible at the byte level for shared frame kinds): + * + * u8 kind โ€” TransportV2BinaryKind + * u32 id โ€” stream / ws / body id (little-endian) + * u32 seq โ€” sequence number for ordering + * u8 flags โ€” TransportV2BinaryFlags bitset + * โ€ฆ payload โ€” opaque bytes + */ +export const TRANSPORT_V2_BINARY_HEADER_SIZE = 10 + +/** A decoded v2 binary frame. */ +export interface TransportV2DecodedBinaryFrame { + kind: TransportV2BinaryKind + id: number + seq: number + flags: number + payload: Uint8Array +} + +/** Encode a v2 binary frame. Pure; allocates a single Uint8Array. */ +export function encodeTransportV2BinaryFrame( + kind: TransportV2BinaryKind, + id: number, + seq: number, + flags: number, + payload: Uint8Array +): Uint8Array { + if (id < 0 || id > 0xffffffff) { + throw new RangeError(`Transport v2 frame id out of range: ${id}`) + } + if (seq < 0 || seq > 0xffffffff) { + throw new RangeError(`Transport v2 frame seq out of range: ${seq}`) + } + if (flags < 0 || flags > 0xff) { + throw new RangeError(`Transport v2 frame flags out of range: ${flags}`) + } + + const frame = new Uint8Array(TRANSPORT_V2_BINARY_HEADER_SIZE + payload.byteLength) + const view = new DataView(frame.buffer) + + view.setUint8(0, kind) + view.setUint32(1, id, true) + view.setUint32(5, seq, true) + view.setUint8(9, flags) + + frame.set(payload, TRANSPORT_V2_BINARY_HEADER_SIZE) + + return frame +} + +/** Decode a v2 binary frame. Pure; returns a view that aliases the input bytes. */ +export function decodeTransportV2BinaryFrame(frame: Uint8Array): TransportV2DecodedBinaryFrame { + if (frame.byteLength < TRANSPORT_V2_BINARY_HEADER_SIZE) { + throw new Error( + `Invalid transport v2 binary frame: too short (${frame.byteLength} bytes, need at least ${TRANSPORT_V2_BINARY_HEADER_SIZE})` + ) + } + + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) + const kind = view.getUint8(0) + + if (kind !== TransportV2BinaryKind.StreamChunk + && kind !== TransportV2BinaryKind.WsData + && kind !== TransportV2BinaryKind.BodyChunk) { + throw new Error(`Invalid transport v2 binary frame: unknown kind ${kind}`) + } + + return { + kind: kind as TransportV2BinaryKind, + id: view.getUint32(1, true), + seq: view.getUint32(5, true), + flags: view.getUint8(9), + payload: frame.subarray(TRANSPORT_V2_BINARY_HEADER_SIZE) + } +} + +/** True when the FIN flag is set on a v2 binary frame. */ +export function transportV2IsFin(flags: number): boolean { + return (flags & TransportV2BinaryFlags.FIN) !== 0 +} + +/** True when the TEXT flag is set on a v2 binary frame. */ +export function transportV2IsText(flags: number): boolean { + return (flags & TransportV2BinaryFlags.TEXT) !== 0 +} + +/** True when the ABORT flag is set on a v2 binary frame. */ +export function transportV2IsAbort(flags: number): boolean { + return (flags & TransportV2BinaryFlags.ABORT) !== 0 +} + +// ----------------------------------------------------------------------------- +// Control plane โ€” parse / stringify +// ----------------------------------------------------------------------------- + +const KNOWN_V2_CONTROL_TYPES = new Set([ + 'hello', + 'welcome', + 'body.open', + 'body.end', + 'body.abort' +]) + +/** + * Parse a JSON string as a v2-specific control message. Returns the typed + * message or throws if the payload is not a recognised v2 control type. + * + * Callers that need to multiplex v1 `JsonMsg` and v2 control messages should + * inspect the `t` field first and dispatch accordingly; this helper is + * deliberately strict so that v2-only code paths never silently accept v1 + * payloads. + */ +export function parseTransportV2ControlMsg(data: string): TransportV2ControlMsg { + const msg = JSON.parse(data) as TransportV2ControlMsg + + if (typeof msg !== 'object' || msg === null || !('t' in msg)) { + throw new Error('Invalid transport v2 control message: missing type field') + } + if (!KNOWN_V2_CONTROL_TYPES.has(msg.t)) { + throw new Error(`Invalid transport v2 control message: unknown type "${msg.t}"`) + } + + if (msg.t === 'hello' || msg.t === 'welcome') { + if (msg.protocolVersion !== TRANSPORT_V2_PROTOCOL_VERSION) { + throw new Error( + `Invalid transport v2 ${msg.t}: protocolVersion ${msg.protocolVersion} != ${TRANSPORT_V2_PROTOCOL_VERSION}` + ) + } + if (!Array.isArray(msg.capabilities)) { + throw new Error(`Invalid transport v2 ${msg.t}: capabilities must be an array`) + } + } + + return msg +} + +/** Stringify a v2 control message. Pure. */ +export function stringifyTransportV2ControlMsg(msg: TransportV2ControlMsg): string { + return JSON.stringify(msg) +} + +// ----------------------------------------------------------------------------- +// Capability negotiation +// ----------------------------------------------------------------------------- + +/** + * Compute the capability intersection a server should announce in its + * `welcome` reply, given its own supported set and the client's `hello` + * advertisement. Order is deterministic (sorted) so the wire output is + * reproducible across runs. + */ +export function negotiateTransportV2Capabilities( + supported: readonly string[], + advertised: readonly string[] +): string[] { + const supportedSet = new Set(supported) + const intersection = advertised.filter((cap) => supportedSet.has(cap)) + return [...new Set(intersection)].sort() +} diff --git a/packages/devflare/src/bridge/v2/index.ts b/packages/devflare/src/bridge/v2/index.ts new file mode 100644 index 0000000..fc5ff9a --- /dev/null +++ b/packages/devflare/src/bridge/v2/index.ts @@ -0,0 +1,77 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Public Surface +// ============================================================================= +// +// Phase 2/3 of the F09/F11 program landed the codec, body streams, in-memory +// transport pair, and streaming request/response serialization. None of this +// is wired into the existing `BridgeServer` / `BridgeClient` / +// `gateway-runtime.ts` modules yet โ€” see `../TRANSPORT_V2.md` for the +// migration plan. +// ============================================================================= + +export { + TRANSPORT_V2_PROTOCOL_VERSION, + TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE, + TRANSPORT_V2_BINARY_HEADER_SIZE, + TransportV2BinaryKind, + TransportV2BinaryFlags, + encodeTransportV2BinaryFrame, + decodeTransportV2BinaryFrame, + transportV2IsFin, + transportV2IsText, + transportV2IsAbort, + parseTransportV2ControlMsg, + stringifyTransportV2ControlMsg, + negotiateTransportV2Capabilities +} from './frames' +export type { + TransportV2Hello, + TransportV2Welcome, + TransportV2BodyKind, + TransportV2BodyOpen, + TransportV2BodyEnd, + TransportV2BodyAbort, + TransportV2ControlMsg, + TransportV2DecodedBinaryFrame +} from './frames' + +export { + TRANSPORT_V2_DEFAULT_BODY_CHUNK_SIZE, + TransportV2BodyReaderRegistry, + writeTransportV2Body +} from './body-streams' +export type { + TransportV2BodyWriterIo, + TransportV2BodyWriterOptions +} from './body-streams' + +export { TransportV2Codec } from './codec' +export type { + TransportV2CodecOptions, + TransportV2HandshakeOk, + TransportV2RpcCall, + TransportV2RpcErr, + TransportV2RpcMsg, + TransportV2RpcOk, + TransportV2WireError +} from './codec' + +export { createTransportV2Pair } from './transport' +export type { + TransportV2InMemoryPair, + WebSocketLike, + WebSocketLikeCloseEvent, + WebSocketLikeMessageEvent +} from './transport' + +export { + deserializeRequestV2, + deserializeResponseV2, + serializeRequestV2, + serializeResponseV2 +} from './serialization' +export type { + TransportV2BodyRef, + TransportV2SerializedRequest, + TransportV2SerializedResponse +} from './serialization' diff --git a/packages/devflare/src/bridge/v2/serialization.ts b/packages/devflare/src/bridge/v2/serialization.ts new file mode 100644 index 0000000..1827da5 --- /dev/null +++ b/packages/devflare/src/bridge/v2/serialization.ts @@ -0,0 +1,168 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Streaming Request/Response Serialization +// ============================================================================= +// +// The v2 counterpart to `../serialization.ts`. Bodies are NEVER buffered: +// every non-empty `Request` / `Response` body is emitted as a v2 body stream +// (body.open + BodyChunk... + body.end), and the wire shape only carries the +// body id, not the bytes. This is the key delta from v1 buffered transport. +// +// Empty bodies still skip the stream entirely so trivial messages don't pay +// the per-stream control-frame cost. +// ============================================================================= + +import { writeTransportV2Body } from './body-streams' +import type { TransportV2Codec } from './codec' + +/** Wire-shape body reference for v2: either absent, an empty marker, or a stream id. */ +export type TransportV2BodyRef = + | { type: 'empty' } + | { type: 'stream'; bid: number; contentType?: string; contentLength?: number } + +export interface TransportV2SerializedRequest { + url: string + method: string + headers: [string, string][] + body: TransportV2BodyRef | null + redirect?: 'follow' | 'error' | 'manual' +} + +export interface TransportV2SerializedResponse { + status: number + statusText?: string + headers: [string, string][] + body: TransportV2BodyRef | null +} + +/** + * Serialize a `Request` for v2 transport. If the request has a non-empty + * body, allocates a body id from the codec, returns a body-stream reference + * in the serialized payload, and immediately starts streaming the body + * frames in the background. The returned `bodyStreamPromise` resolves once + * the body has been fully written (or rejects on body source error). + */ +export function serializeRequestV2( + request: Request, + codec: TransportV2Codec, + rpcId: string +): { serialized: TransportV2SerializedRequest; bodyStreamPromise: Promise } { + const headers: [string, string][] = [] + request.headers.forEach((value, key) => { + headers.push([key, value]) + }) + + const result = streamBodyIfPresent(request.body, request.headers, codec, rpcId, 'request') + + return { + serialized: { + url: request.url, + method: request.method, + headers, + body: result.ref, + redirect: request.redirect as 'follow' | 'error' | 'manual' + }, + bodyStreamPromise: result.streamPromise + } +} + +/** + * Deserialize a v2-wire `Request`. If the payload references a body stream, + * the matching `ReadableStream` from the codec's body-reader + * registry is attached as the request body. + */ +export function deserializeRequestV2( + serialized: TransportV2SerializedRequest, + codec: TransportV2Codec +): Request { + const body = bodyFromRef(serialized.body, codec) + return new Request(serialized.url, { + method: serialized.method, + headers: serialized.headers, + body, + redirect: serialized.redirect + }) +} + +/** Serialize a `Response` for v2 transport. See `serializeRequestV2`. */ +export function serializeResponseV2( + response: Response, + codec: TransportV2Codec, + rpcId: string +): { serialized: TransportV2SerializedResponse; bodyStreamPromise: Promise } { + const headers: [string, string][] = [] + response.headers.forEach((value, key) => { + headers.push([key, value]) + }) + + const result = streamBodyIfPresent(response.body, response.headers, codec, rpcId, 'response') + + return { + serialized: { + status: response.status, + statusText: response.statusText, + headers, + body: result.ref + }, + bodyStreamPromise: result.streamPromise + } +} + +/** Deserialize a v2-wire `Response`. */ +export function deserializeResponseV2( + serialized: TransportV2SerializedResponse, + codec: TransportV2Codec +): Response { + const body = bodyFromRef(serialized.body, codec) + return new Response(body, { + status: serialized.status, + statusText: serialized.statusText, + headers: serialized.headers + }) +} + +function streamBodyIfPresent( + body: ReadableStream | null, + headers: Headers, + codec: TransportV2Codec, + rpcId: string, + kind: 'request' | 'response' +): { ref: TransportV2BodyRef | null; streamPromise: Promise } { + if (body === null) { + return { ref: null, streamPromise: Promise.resolve() } + } + + const bid = codec.allocateBid() + const contentType = headers.get('content-type') ?? undefined + const contentLengthHeader = headers.get('content-length') + const contentLength = contentLengthHeader !== null ? Number(contentLengthHeader) : undefined + const ref: TransportV2BodyRef = { + type: 'stream', + bid, + ...(contentType !== undefined ? { contentType } : {}), + ...(contentLength !== undefined && Number.isFinite(contentLength) ? { contentLength } : {}) + } + + const streamPromise = writeTransportV2Body(body, { + bid, + kind, + rpcId, + io: { + sendText: (message) => codec.sendText(message), + sendBinary: (frame) => codec.sendBinary(frame) + }, + writerOptions: { + ...(contentType !== undefined ? { contentType } : {}), + ...(contentLength !== undefined && Number.isFinite(contentLength) ? { contentLength } : {}) + } + }) + + return { ref, streamPromise } +} + +function bodyFromRef( + ref: TransportV2BodyRef | null, + codec: TransportV2Codec +): BodyInit | null { + if (ref === null || ref.type === 'empty') return null + return codec.openBodyReader(ref.bid) as unknown as BodyInit +} diff --git a/packages/devflare/src/bridge/v2/transport.ts b/packages/devflare/src/bridge/v2/transport.ts new file mode 100644 index 0000000..4e4fb66 --- /dev/null +++ b/packages/devflare/src/bridge/v2/transport.ts @@ -0,0 +1,94 @@ +// ============================================================================= +// Bridge Transport v2 โ€” WebSocket Abstraction + In-Memory Transport Pair +// ============================================================================= +// +// `WebSocketLike` is the minimal duplex interface the v2 codec depends on. +// It is satisfied by the standard WebSocket APIs used by both Node `ws` and +// the workerd-side WebSocket, which is what allows the same codec to run on +// both ends of the bridge without conditional logic. +// +// `createTransportV2Pair()` produces two paired in-memory transports for +// tests: anything written to A.send() arrives on B's 'message' listeners and +// vice versa. There is no network involved. +// ============================================================================= + +export interface WebSocketLikeMessageEvent { + data: string | ArrayBuffer | Uint8Array +} + +export interface WebSocketLikeCloseEvent { + code: number + reason: string +} + +/** + * Minimal WebSocket-shaped duplex transport interface used by the v2 codec. + * + * The handler-property style (`onmessage`, `onclose`, `onerror`) matches both + * the standard browser WebSocket API and the Node `ws` package's compat layer, + * so the codec can attach without adapters. + */ +export interface WebSocketLike { + send(data: string | Uint8Array): void + close(code?: number, reason?: string): void + onmessage: ((event: WebSocketLikeMessageEvent) => void) | null + onclose: ((event: WebSocketLikeCloseEvent) => void) | null + onerror: ((event: { error?: unknown }) => void) | null +} + +/** A linked pair of in-memory transports used by tests. */ +export interface TransportV2InMemoryPair { + a: WebSocketLike + b: WebSocketLike +} + +class InMemoryTransport implements WebSocketLike { + onmessage: ((event: WebSocketLikeMessageEvent) => void) | null = null + onclose: ((event: WebSocketLikeCloseEvent) => void) | null = null + onerror: ((event: { error?: unknown }) => void) | null = null + #peer: InMemoryTransport | null = null + #closed = false + + bind(peer: InMemoryTransport): void { + this.#peer = peer + } + + send(data: string | Uint8Array): void { + if (this.#closed) { + throw new Error('cannot send on a closed v2 in-memory transport') + } + const peer = this.#peer + if (peer === null) { + throw new Error('v2 in-memory transport has no bound peer') + } + // Defer delivery to the microtask queue so that the call stack of the + // sender unwinds before the receiver runs, matching real WebSocket + // semantics where message handlers are never invoked re-entrantly. + queueMicrotask(() => { + if (peer.#closed) return + peer.onmessage?.({ data }) + }) + } + + close(code = 1000, reason = ''): void { + if (this.#closed) return + this.#closed = true + const peer = this.#peer + queueMicrotask(() => { + this.onclose?.({ code, reason }) + if (peer !== null && !peer.#closed) { + peer.#closed = true + peer.onclose?.({ code, reason }) + } + }) + } +} + +/** Create two v2 in-memory transports linked to each other. */ +export function createTransportV2Pair(): TransportV2InMemoryPair { + const a = new InMemoryTransport() + const b = new InMemoryTransport() + a.bind(b) + b.bind(a) + return { a, b } +} diff --git a/packages/devflare/src/bridge/v2/value-codec.ts b/packages/devflare/src/bridge/v2/value-codec.ts new file mode 100644 index 0000000..cd474dd --- /dev/null +++ b/packages/devflare/src/bridge/v2/value-codec.ts @@ -0,0 +1,397 @@ +๏ปฟ// ============================================================================= +// Transport v2 โ€” Generic value codec +// ============================================================================= +// Pure JSON-friendly serialization for arbitrary RPC params/results. +// Special objects (Date/Map/Set/URL/Error/Uint8Array/ArrayBuffer/Request/ +// Response/ReadableStream/DurableObjectId/R2Object/R2ObjectBody) are encoded +// as tagged POJOs so a JSON.stringify round-trip preserves identity. Bodies +// that exceed the JSON envelope travel as v2 body streams; the serialized +// form references them by `bid`. +// ============================================================================= + +import { writeTransportV2Body } from './body-streams' +import { + serializeRequestV2, + deserializeRequestV2, + serializeResponseV2, + deserializeResponseV2 +} from './serialization' +import type { TransportV2SerializedRequest, TransportV2SerializedResponse } from './serialization' +import type { TransportV2Codec } from './codec' + +// ----------------------------------------------------------------------------- +// Tagged shapes +// ----------------------------------------------------------------------------- + +export const TRANSPORT_V2_DO_ID_TYPE = 'DOId' as const + +export interface TransportV2SerializedDOId { + __type: typeof TRANSPORT_V2_DO_ID_TYPE + hex: string +} + +export type TransportV2SerializedSpecial = + | { __devflare: 'date'; iso: string } + | { __devflare: 'map'; entries: [unknown, unknown][] } + | { __devflare: 'set'; values: unknown[] } + | { __devflare: 'url'; href: string } + | { __devflare: 'error'; name: string; message: string; stack?: string } + +export interface TransportV2SerializedR2Object { + __type: 'R2Object' | 'R2ObjectBody' + key: string + version: string + size: number + etag: string + httpEtag: string + checksums: R2Checksums + uploaded?: string + httpMetadata?: R2HTTPMetadata + customMetadata?: Record + range?: R2Range + storageClass?: string + bodyData?: string +} + +export interface TransportV2BodyStreamRef { + __type: 'BodyStream' + bid: number +} + +// ----------------------------------------------------------------------------- +// base64 helpers (Node/Bun fast path + browser fallback) +// ----------------------------------------------------------------------------- + +export function base64Encode(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64') + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary) +} + +export function base64Decode(str: string): Uint8Array { + if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(str, 'base64')) + const binary = atob(str) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +// ----------------------------------------------------------------------------- +// DurableObjectId helpers +// ----------------------------------------------------------------------------- + +export function serializeTransportV2DOId(id: DurableObjectId): TransportV2SerializedDOId { + return { __type: TRANSPORT_V2_DO_ID_TYPE, hex: id.toString() } +} + +export function deserializeTransportV2DOId( + serialized: TransportV2SerializedDOId | { __type?: unknown; hex?: unknown }, + ns: DurableObjectNamespace +): DurableObjectId { + if (serialized && (serialized as TransportV2SerializedDOId).__type === TRANSPORT_V2_DO_ID_TYPE) { + return ns.idFromString((serialized as TransportV2SerializedDOId).hex) + } + throw new Error('Invalid DOId format') +} + +// ----------------------------------------------------------------------------- +// Generic value codec +// ----------------------------------------------------------------------------- + +export interface TransportV2ValueCtx { + codec?: TransportV2Codec + conversationId?: string + bodyStreamPromises?: Promise[] + bodyKind?: 'request' | 'response' | 'value' +} + +export async function serializeTransportV2Value( + value: unknown, + ctx: TransportV2ValueCtx = {} +): Promise { + return serializeInternal(value, ctx) +} + +export function deserializeTransportV2Value( + value: unknown, + ctx: TransportV2ValueCtx = {} +): unknown { + return deserializeInternal(value, ctx) +} + +async function serializeInternal(value: unknown, ctx: TransportV2ValueCtx): Promise { + if (value === null || value === undefined) return value + + if (value instanceof Request) { + if (!ctx.codec) throw new Error('serializeTransportV2Value: Request requires ctx.codec') + const result = serializeRequestV2(value, ctx.codec, ctx.conversationId ?? '') + ctx.bodyStreamPromises?.push(result.bodyStreamPromise) + return { __type: 'Request', ...result.serialized } + } + + if (value instanceof Response) { + if (!ctx.codec) throw new Error('serializeTransportV2Value: Response requires ctx.codec') + const result = serializeResponseV2(value, ctx.codec, ctx.conversationId ?? '') + ctx.bodyStreamPromises?.push(result.bodyStreamPromise) + return { __type: 'Response', ...result.serialized } + } + + if (value instanceof ReadableStream) { + if (!ctx.codec) throw new Error('serializeTransportV2Value: ReadableStream requires ctx.codec') + const codec = ctx.codec + const bid = codec.allocateBid() + const promise = writeTransportV2Body(value as ReadableStream, { + bid, + kind: ctx.bodyKind ?? 'value', + rpcId: ctx.conversationId ?? '', + io: { + sendText: (m) => codec.sendText(m), + sendBinary: (f) => codec.sendBinary(f) + } + }) + ctx.bodyStreamPromises?.push(promise) + return { __type: 'BodyStream', bid } satisfies TransportV2BodyStreamRef + } + + if (value instanceof Uint8Array) { + return { __type: 'Uint8Array', data: base64Encode(value) } + } + + if (value instanceof ArrayBuffer) { + return { __type: 'ArrayBuffer', data: base64Encode(new Uint8Array(value)) } + } + + if (value instanceof Date) { + return { __devflare: 'date', iso: value.toISOString() } satisfies TransportV2SerializedSpecial + } + + if (value instanceof URL) { + return { __devflare: 'url', href: value.href } satisfies TransportV2SerializedSpecial + } + + if (value instanceof Error) { + const encoded: TransportV2SerializedSpecial = { + __devflare: 'error', + name: value.name, + message: value.message + } + if (value.stack) encoded.stack = value.stack + return encoded + } + + if (value instanceof Map) { + const entries: [unknown, unknown][] = [] + for (const [k, v] of value.entries()) { + entries.push([await serializeInternal(k, ctx), await serializeInternal(v, ctx)]) + } + return { __devflare: 'map', entries } satisfies TransportV2SerializedSpecial + } + + if (value instanceof Set) { + const values: unknown[] = [] + for (const v of value.values()) values.push(await serializeInternal(v, ctx)) + return { __devflare: 'set', values } satisfies TransportV2SerializedSpecial + } + + if (Array.isArray(value)) { + return Promise.all(value.map((v) => serializeInternal(v, ctx))) + } + + if (typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value)) out[k] = await serializeInternal(v, ctx) + return out + } + + return value +} + +function deserializeInternal(value: unknown, ctx: TransportV2ValueCtx): unknown { + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + + if (Array.isArray(value)) { + return value.map((v) => deserializeInternal(v, ctx)) + } + + const obj = value as Record + + if (typeof obj.__devflare === 'string') { + switch (obj.__devflare) { + case 'date': + return new Date(obj.iso as string) + case 'url': + return new URL(obj.href as string) + case 'error': { + const err = new Error(obj.message as string) + if (typeof obj.name === 'string') err.name = obj.name + if (typeof obj.stack === 'string') err.stack = obj.stack + return err + } + case 'map': { + const entries = (obj.entries as [unknown, unknown][]) ?? [] + const map = new Map() + for (const [k, v] of entries) { + map.set(deserializeInternal(k, ctx), deserializeInternal(v, ctx)) + } + return map + } + case 'set': { + const values = (obj.values as unknown[]) ?? [] + const set = new Set() + for (const v of values) set.add(deserializeInternal(v, ctx)) + return set + } + } + } + + if (obj.__type === 'Request') { + if (!ctx.codec) throw new Error('deserializeTransportV2Value: Request requires ctx.codec') + return deserializeRequestV2(obj as unknown as TransportV2SerializedRequest, ctx.codec) + } + + if (obj.__type === 'Response') { + if (!ctx.codec) throw new Error('deserializeTransportV2Value: Response requires ctx.codec') + return deserializeResponseV2(obj as unknown as TransportV2SerializedResponse, ctx.codec) + } + + if (obj.__type === 'BodyStream') { + if (!ctx.codec) throw new Error('deserializeTransportV2Value: BodyStream requires ctx.codec') + return ctx.codec.openBodyReader(obj.bid as number) + } + + if (obj.__type === 'Uint8Array') { + return base64Decode(obj.data as string) + } + + if (obj.__type === 'ArrayBuffer') { + return base64Decode(obj.data as string).buffer + } + + if (obj.__type === 'R2Object') return deserializeR2Object(obj) + if (obj.__type === 'R2ObjectBody') return deserializeR2ObjectBody(obj) + + const out: Record = {} + for (const [k, v] of Object.entries(obj)) out[k] = deserializeInternal(v, ctx) + return out +} + +// ----------------------------------------------------------------------------- +// R2 helpers (mirror v1 wire shape) +// ----------------------------------------------------------------------------- + +export function serializeR2Object(obj: R2Object | null): unknown { + if (!obj) return null + return { + __type: 'R2Object', + key: obj.key, + version: obj.version, + size: obj.size, + etag: obj.etag, + httpEtag: obj.httpEtag, + checksums: obj.checksums, + uploaded: obj.uploaded?.toISOString(), + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + range: obj.range, + storageClass: obj.storageClass + } +} + +export async function serializeR2ObjectBody(obj: R2ObjectBody | R2Object | null): Promise { + if (!obj) return null + + const hasBody = 'body' in obj || 'arrayBuffer' in obj + if (!hasBody) return serializeR2Object(obj as R2Object) + + const body = obj as R2ObjectBody + const arrayBuffer = await body.arrayBuffer() + const bodyData = base64Encode(new Uint8Array(arrayBuffer)) + + return { + __type: 'R2ObjectBody', + key: body.key, + version: body.version, + size: body.size, + etag: body.etag, + httpEtag: body.httpEtag, + checksums: body.checksums, + uploaded: body.uploaded?.toISOString(), + httpMetadata: body.httpMetadata, + customMetadata: body.customMetadata, + range: body.range, + storageClass: body.storageClass, + bodyData + } +} + +function applySerializedHttpMetadata(headers: Headers, httpMetadata?: R2HTTPMetadata): void { + if (httpMetadata?.contentType) headers.set('Content-Type', httpMetadata.contentType) + if (httpMetadata?.contentLanguage) headers.set('Content-Language', httpMetadata.contentLanguage) + if (httpMetadata?.contentDisposition) headers.set('Content-Disposition', httpMetadata.contentDisposition) + if (httpMetadata?.contentEncoding) headers.set('Content-Encoding', httpMetadata.contentEncoding) + if (httpMetadata?.cacheControl) headers.set('Cache-Control', httpMetadata.cacheControl) + if (httpMetadata?.cacheExpiry) headers.set('Expires', new Date(httpMetadata.cacheExpiry).toUTCString()) +} + +function createSerializedR2Metadata(serialized: TransportV2SerializedR2Object) { + return { + key: serialized.key, + version: serialized.version, + size: serialized.size, + etag: serialized.etag, + httpEtag: serialized.httpEtag, + checksums: serialized.checksums, + uploaded: serialized.uploaded ? new Date(serialized.uploaded) : new Date(), + httpMetadata: serialized.httpMetadata, + customMetadata: serialized.customMetadata, + range: serialized.range, + storageClass: serialized.storageClass as R2Object['storageClass'], + writeHttpMetadata(headers: Headers): void { + applySerializedHttpMetadata(headers, serialized.httpMetadata) + } + } +} + +function deserializeR2Object(obj: Record): R2Object { + const serialized = obj as unknown as TransportV2SerializedR2Object + return { ...createSerializedR2Metadata(serialized) } as R2Object +} + +function deserializeR2ObjectBody(obj: Record): R2ObjectBody { + const serialized = obj as unknown as TransportV2SerializedR2Object + const bodyBytes = serialized.bodyData ? base64Decode(serialized.bodyData) : new Uint8Array(0) + + const r2ObjectBody = { + ...createSerializedR2Metadata(serialized), + body: new ReadableStream({ + start(controller) { + controller.enqueue(bodyBytes) + controller.close() + } + }), + bodyUsed: false, + async arrayBuffer(): Promise { + const copy = new Uint8Array(bodyBytes.byteLength) + copy.set(bodyBytes) + return copy.buffer + }, + async text(): Promise { + return new TextDecoder().decode(bodyBytes) + }, + async json(): Promise { + return JSON.parse(new TextDecoder().decode(bodyBytes)) + }, + async blob(): Promise { + const contentType = serialized.httpMetadata?.contentType || 'application/octet-stream' + const buffer = bodyBytes.buffer.slice( + bodyBytes.byteOffset, + bodyBytes.byteOffset + bodyBytes.byteLength + ) as ArrayBuffer + return new Blob([buffer], { type: contentType }) + } + } + + return r2ObjectBody as unknown as R2ObjectBody +} diff --git a/packages/devflare/src/bridge/v2/value-serialization.ts b/packages/devflare/src/bridge/v2/value-serialization.ts new file mode 100644 index 0000000..1232a45 --- /dev/null +++ b/packages/devflare/src/bridge/v2/value-serialization.ts @@ -0,0 +1,598 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Value Serialization (Request/Response/special types) +// ============================================================================= +// Converts Web API objects (Request, Response, ReadableStream, Date, Map, +// Set, URL, Error, Uint8Array, ArrayBuffer, R2Object, R2ObjectBody) and +// `DurableObjectId` to/from serializable POJOs for transport across the +// bridge. Tagged-POJO format is the canonical wire shape used by every +// gateway runtime variant. +// ============================================================================= + +import { nextStreamId } from './wire' + +// ----------------------------------------------------------------------------- +// Serialized Types +// ----------------------------------------------------------------------------- + +/** Serialized HTTP Request */ +export interface SerializedRequest { + url: string + method: string + headers: [string, string][] + body?: BodyRef | null + redirect?: 'follow' | 'error' | 'manual' + cf?: unknown +} + +/** Serialized HTTP Response */ +export interface SerializedResponse { + status: number + statusText?: string + headers: [string, string][] + body?: BodyRef | null + webSocket?: { wid: number } +} + +/** Reference to a body - either inline bytes or stream */ +export type BodyRef = + | { type: 'bytes'; data: string } // base64 for JSON transport + | { type: 'stream'; sid: number } + +/** + * Canonical wire discriminator for a serialized `DurableObjectId`. + * + * Kept as a shared constant so the TypeScript serializers in `server.ts` and + * the stringified gateway runtime (`gateway-runtime.ts`) agree on a single + * shape. DO NOT change without coordinating both sides of the bridge. + */ +export const DO_ID_TYPE = 'DOId' as const + +/** Serialized DurableObjectId โ€” matches the wire shape emitted by every gateway variant. */ +export interface SerializedDOId { + __type: typeof DO_ID_TYPE + hex: string +} + +// ----------------------------------------------------------------------------- +// Request Serialization +// ----------------------------------------------------------------------------- + +/** Serialize a Request to a POJO */ +export async function serializeRequest( + request: Request, + options?: { httpThreshold?: number } +): Promise<{ serialized: SerializedRequest; streams: StreamRef[] }> { + const streams: StreamRef[] = [] + const threshold = options?.httpThreshold ?? 10 * 1024 * 1024 + + const headers: [string, string][] = [] + request.headers.forEach((value, key) => { + headers.push([key, value]) + }) + + let body: BodyRef | null = null + + if (request.body) { + // Always read the body as bytes for reliability + // Stream handling is complex and often unreliable across RPC + const bytes = await request.arrayBuffer() + + if (bytes.byteLength > threshold) { + // The HTTP body-transfer path was never wired up end-to-end on the + // deserialize side. Fail loudly so future wiring cannot silently + // produce placeholder bodies. + throw new Error('http body transfer not implemented; caller should use inline or binary transfer') + } else if (bytes.byteLength > 0) { + // Body has content โ†’ inline bytes (base64) + body = { type: 'bytes', data: base64Encode(new Uint8Array(bytes)) } + } + // Empty body (0 bytes) โ†’ body stays null + } + + return { + serialized: { + url: request.url, + method: request.method, + headers, + body, + redirect: request.redirect as 'follow' | 'error' | 'manual' + }, + streams + } +} + +/** Deserialize a Request from a POJO */ +export function deserializeRequest( + serialized: SerializedRequest, + getStream?: (sid: number) => ReadableStream | null +): Request { + let body: BodyInit | null = null + + if (serialized.body) { + switch (serialized.body.type) { + case 'bytes': + // Cast needed for TypeScript strict mode (Uint8Array vs Uint8Array) + body = base64Decode(serialized.body.data) as unknown as BodyInit + break + case 'stream': + if (getStream) { + body = getStream(serialized.body.sid) ?? null + } + break + } + } + + return new Request(serialized.url, { + method: serialized.method, + headers: serialized.headers, + body, + redirect: serialized.redirect + }) +} + +// ----------------------------------------------------------------------------- +// Response Serialization +// ----------------------------------------------------------------------------- + +/** Serialize a Response to a POJO */ +export async function serializeResponse( + response: Response, + options?: { httpThreshold?: number } +): Promise<{ serialized: SerializedResponse; streams: StreamRef[] }> { + const streams: StreamRef[] = [] + const threshold = options?.httpThreshold ?? 10 * 1024 * 1024 + + const headers: [string, string][] = [] + response.headers.forEach((value, key) => { + headers.push([key, value]) + }) + + let body: BodyRef | null = null + + if (response.body) { + // Always read the body as bytes for reliability + // Stream handling is complex and often unreliable across RPC + const bytes = await response.arrayBuffer() + + if (bytes.byteLength > threshold) { + // The HTTP body-transfer path was never wired up end-to-end on the + // deserialize side. Fail loudly so future wiring cannot silently + // produce placeholder bodies. + throw new Error('http body transfer not implemented; caller should use inline or binary transfer') + } else if (bytes.byteLength > 0) { + // Body has content โ†’ inline bytes (base64) + body = { type: 'bytes', data: base64Encode(new Uint8Array(bytes)) } + } + // Empty body (0 bytes) โ†’ body stays null + } + + return { + serialized: { + status: response.status, + statusText: response.statusText, + headers, + body + }, + streams + } +} + +/** Deserialize a Response from a POJO */ +export function deserializeResponse( + serialized: SerializedResponse, + getStream?: (sid: number) => ReadableStream | null +): Response { + let body: BodyInit | null = null + + if (serialized.body) { + switch (serialized.body.type) { + case 'bytes': + // Cast needed for TypeScript strict mode (Uint8Array vs Uint8Array) + body = base64Decode(serialized.body.data) as unknown as BodyInit + break + case 'stream': + if (getStream) { + body = getStream(serialized.body.sid) ?? null + } + break + } + } + + return new Response(body, { + status: serialized.status, + statusText: serialized.statusText, + headers: serialized.headers + }) +} + +// ----------------------------------------------------------------------------- +// Stream References +// ----------------------------------------------------------------------------- + +/** Reference to a stream that needs to be sent separately */ +export interface StreamRef { + sid: number + stream: ReadableStream +} + +// ----------------------------------------------------------------------------- +// Durable Object Serialization +// ----------------------------------------------------------------------------- + +/** Serialize a DurableObjectId to the canonical wire shape. */ +export function serializeDOId(id: DurableObjectId): SerializedDOId { + return { __type: DO_ID_TYPE, hex: id.toString() } +} + +/** Deserialize a canonical `SerializedDOId` back into a `DurableObjectId` bound to `ns`. */ +export function deserializeDOId( + serialized: SerializedDOId | { __type?: unknown, hex?: unknown }, + ns: DurableObjectNamespace +): DurableObjectId { + if (serialized && (serialized as SerializedDOId).__type === DO_ID_TYPE) { + return ns.idFromString((serialized as SerializedDOId).hex) + } + throw new Error('Invalid DOId format') +} + +// ----------------------------------------------------------------------------- +// Value Serialization (generic) +// ----------------------------------------------------------------------------- + +/** Check if a value needs special serialization */ +export function needsSpecialSerialization(value: unknown): boolean { + if (value === null || value === undefined) return false + if (value instanceof Request) return true + if (value instanceof Response) return true + if (value instanceof ReadableStream) return true + if (value instanceof Uint8Array) return true + if (value instanceof ArrayBuffer) return true + if (value instanceof Date) return true + if (value instanceof Map) return true + if (value instanceof Set) return true + if (value instanceof URL) return true + if (value instanceof Error) return true + return false +} + +/** Discriminator tag for structurally-encoded special values */ +export type SerializedSpecial = + | { __devflare: 'date', iso: string } + | { __devflare: 'map', entries: [unknown, unknown][] } + | { __devflare: 'set', values: unknown[] } + | { __devflare: 'url', href: string } + | { __devflare: 'error', name: string, message: string, stack?: string } + +/** Serialize a value that may contain special types */ +export async function serializeValue(value: unknown): Promise<{ + value: unknown + streams: StreamRef[] +}> { + const streams: StreamRef[] = [] + + const result = await serializeValueInternal(value, streams) + + return { value: result, streams } +} + +async function serializeValueInternal( + value: unknown, + streams: StreamRef[] +): Promise { + if (value === null || value === undefined) { + return value + } + + if (value instanceof Request) { + const { serialized, streams: reqStreams } = await serializeRequest(value) + streams.push(...reqStreams) + return { __type: 'Request', ...serialized } + } + + if (value instanceof Response) { + const { serialized, streams: resStreams } = await serializeResponse(value) + streams.push(...resStreams) + return { __type: 'Response', ...serialized } + } + + if (value instanceof ReadableStream) { + const sid = nextStreamId() + streams.push({ sid, stream: value }) + return { __type: 'ReadableStream', sid } + } + + if (value instanceof Uint8Array) { + return { __type: 'Uint8Array', data: base64Encode(value) } + } + + if (value instanceof ArrayBuffer) { + return { __type: 'ArrayBuffer', data: base64Encode(new Uint8Array(value)) } + } + + if (value instanceof Date) { + return { __devflare: 'date', iso: value.toISOString() } satisfies SerializedSpecial + } + + if (value instanceof URL) { + return { __devflare: 'url', href: value.href } satisfies SerializedSpecial + } + + if (value instanceof Error) { + const encoded: SerializedSpecial = { + __devflare: 'error', + name: value.name, + message: value.message + } + if (value.stack) encoded.stack = value.stack + return encoded + } + + if (value instanceof Map) { + const entries: [unknown, unknown][] = [] + for (const [k, v] of value.entries()) { + entries.push([ + await serializeValueInternal(k, streams), + await serializeValueInternal(v, streams) + ]) + } + return { __devflare: 'map', entries } satisfies SerializedSpecial + } + + if (value instanceof Set) { + const values: unknown[] = [] + for (const v of value.values()) { + values.push(await serializeValueInternal(v, streams)) + } + return { __devflare: 'set', values } satisfies SerializedSpecial + } + + if (Array.isArray(value)) { + return Promise.all(value.map((v) => serializeValueInternal(v, streams))) + } + + if (typeof value === 'object') { + const result: Record = {} + for (const [k, v] of Object.entries(value)) { + result[k] = await serializeValueInternal(v, streams) + } + return result + } + + return value +} + +/** Deserialize a value that may contain special types */ +export function deserializeValue( + value: unknown, + getStream?: (sid: number) => ReadableStream | null +): unknown { + if (value === null || value === undefined) { + return value + } + + if (typeof value === 'object' && value !== null) { + const obj = value as Record + + if (typeof obj.__devflare === 'string') { + switch (obj.__devflare) { + case 'date': + return new Date(obj.iso as string) + case 'url': + return new URL(obj.href as string) + case 'error': { + const err = new Error(obj.message as string) + if (typeof obj.name === 'string') err.name = obj.name + if (typeof obj.stack === 'string') err.stack = obj.stack + return err + } + case 'map': { + const entries = (obj.entries as [unknown, unknown][]) ?? [] + const map = new Map() + for (const [k, v] of entries) { + map.set(deserializeValue(k, getStream), deserializeValue(v, getStream)) + } + return map + } + case 'set': { + const values = (obj.values as unknown[]) ?? [] + const set = new Set() + for (const v of values) { + set.add(deserializeValue(v, getStream)) + } + return set + } + } + } + + if (obj.__type === 'Request') { + return deserializeRequest(obj as unknown as SerializedRequest, getStream) + } + + if (obj.__type === 'Response') { + return deserializeResponse(obj as unknown as SerializedResponse, getStream) + } + + if (obj.__type === 'ReadableStream') { + const sid = obj.sid as number + return getStream?.(sid) ?? null + } + + if (obj.__type === 'Uint8Array') { + return base64Decode(obj.data as string) + } + + if (obj.__type === 'ArrayBuffer') { + return base64Decode(obj.data as string).buffer + } + + // R2Object (metadata only) + if (obj.__type === 'R2Object') { + return deserializeR2Object(obj) + } + + // R2ObjectBody (with body data) + if (obj.__type === 'R2ObjectBody') { + return deserializeR2ObjectBody(obj) + } + + if (Array.isArray(value)) { + return value.map((v) => deserializeValue(v, getStream)) + } + + const result: Record = {} + for (const [k, v] of Object.entries(obj)) { + result[k] = deserializeValue(v, getStream) + } + return result + } + + return value +} + +// ----------------------------------------------------------------------------- +// R2 Object Helpers +// ----------------------------------------------------------------------------- + +/** Serialized R2 object metadata */ +interface SerializedR2Object { + __type: 'R2Object' | 'R2ObjectBody' + key: string + version: string + size: number + etag: string + httpEtag: string + checksums: R2Checksums + uploaded?: string + httpMetadata?: R2HTTPMetadata + customMetadata?: Record + range?: R2Range + storageClass?: string + bodyData?: string // Base64-encoded body (only for R2ObjectBody) +} + +function applySerializedHttpMetadata( + headers: Headers, + httpMetadata?: R2HTTPMetadata +): void { + if (httpMetadata?.contentType) { + headers.set('Content-Type', httpMetadata.contentType) + } + if (httpMetadata?.contentLanguage) { + headers.set('Content-Language', httpMetadata.contentLanguage) + } + if (httpMetadata?.contentDisposition) { + headers.set('Content-Disposition', httpMetadata.contentDisposition) + } + if (httpMetadata?.contentEncoding) { + headers.set('Content-Encoding', httpMetadata.contentEncoding) + } + if (httpMetadata?.cacheControl) { + headers.set('Cache-Control', httpMetadata.cacheControl) + } + if (httpMetadata?.cacheExpiry) { + headers.set('Expires', new Date(httpMetadata.cacheExpiry).toUTCString()) + } +} + +function createSerializedR2Metadata(serialized: SerializedR2Object) { + return { + key: serialized.key, + version: serialized.version, + size: serialized.size, + etag: serialized.etag, + httpEtag: serialized.httpEtag, + checksums: serialized.checksums, + uploaded: serialized.uploaded ? new Date(serialized.uploaded) : new Date(), + httpMetadata: serialized.httpMetadata, + customMetadata: serialized.customMetadata, + range: serialized.range, + storageClass: serialized.storageClass as any, + writeHttpMetadata(headers: Headers): void { + applySerializedHttpMetadata(headers, serialized.httpMetadata) + } + } +} + +/** Deserialize R2Object (metadata only) */ +function deserializeR2Object(obj: Record): R2Object { + const serialized = obj as unknown as SerializedR2Object + return { + ...createSerializedR2Metadata(serialized) + } as R2Object +} + +/** Deserialize R2ObjectBody (with body data) */ +function deserializeR2ObjectBody(obj: Record): R2ObjectBody { + const serialized = obj as unknown as SerializedR2Object + const bodyBytes = serialized.bodyData ? base64Decode(serialized.bodyData) : new Uint8Array(0) + + // Create a fake R2ObjectBody with working methods + const r2ObjectBody = { + ...createSerializedR2Metadata(serialized), + // Body as ReadableStream + body: new ReadableStream({ + start(controller) { + controller.enqueue(bodyBytes) + controller.close() + } + }), + // Whether body has been consumed + bodyUsed: false, + // Methods to read body + async arrayBuffer(): Promise { + // Copy the relevant portion to a new ArrayBuffer + // This ensures we return a proper ArrayBuffer, not SharedArrayBuffer + const copy = new Uint8Array(bodyBytes.byteLength) + copy.set(bodyBytes) + return copy.buffer + }, + async text(): Promise { + return new TextDecoder().decode(bodyBytes) + }, + async json(): Promise { + const text = new TextDecoder().decode(bodyBytes) + return JSON.parse(text) + }, + async blob(): Promise { + const contentType = serialized.httpMetadata?.contentType || 'application/octet-stream' + // Convert to ArrayBuffer for wider compatibility + const buffer = bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer + return new Blob([buffer], { type: contentType }) + } + } + + return r2ObjectBody as R2ObjectBody +} + +// ----------------------------------------------------------------------------- +// Base64 Utilities +// ----------------------------------------------------------------------------- + +/** Encode Uint8Array to base64 string */ +export function base64Encode(bytes: Uint8Array): string { + // Use Buffer in Node.js/Bun for performance + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64') + } + // Fallback for browser/worker environments + let binary = '' + for (let i = 0;i < bytes.byteLength;i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +/** Decode base64 string to Uint8Array */ +export function base64Decode(str: string): Uint8Array { + // Use Buffer in Node.js/Bun for performance + if (typeof Buffer !== 'undefined') { + return new Uint8Array(Buffer.from(str, 'base64')) + } + // Fallback for browser/worker environments + const binary = atob(str) + const bytes = new Uint8Array(binary.length) + for (let i = 0;i < binary.length;i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} diff --git a/packages/devflare/src/bridge/v2/wire.ts b/packages/devflare/src/bridge/v2/wire.ts new file mode 100644 index 0000000..3953f27 --- /dev/null +++ b/packages/devflare/src/bridge/v2/wire.ts @@ -0,0 +1,316 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Wire Vocabulary (RPC envelope, stream/ws control, +// binary frame format, ID counters) +// ============================================================================= +// JSON control messages (rpc.call/rpc.ok/rpc.err, stream.*, ws.*, event, +// http.transfer) and the 10-byte binary frame header used by every devflare +// bridge transport. Sits below the codec layer (see codec.ts) which adds the +// hello/welcome handshake and the body-stream registry on top of these +// primitives. +// +// Inline-vs-HTTP fallback rule: +// Body bytes below `HTTP_TRANSFER_THRESHOLD` (512 KB) ride inline as binary WS +// frames over the same WebSocket carrying the RPC control plane. Above that +// threshold the bridge switches to an out-of-band HTTP transfer because +// workerd enforces a ~1 MB per-WebSocket-message limit, so a single oversized +// inline frame would be rejected by the runtime before reaching the peer. +// The transport choice is per-payload (decided when each body is serialized), +// not per-connection: the WS stays open for the RPC envelope and the body +// bytes simply travel via an HTTP fetch instead of a WS frame. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Control Plane (JSON text frames) +// ----------------------------------------------------------------------------- + +/** RPC call from client to worker */ +export interface RpcCall { + t: 'rpc.call' + id: string + method: string + params: unknown[] +} + +/** Successful RPC response */ +export interface RpcOk { + t: 'rpc.ok' + id: string + result: unknown +} + +/** Error RPC response */ +export interface RpcErr { + t: 'rpc.err' + id: string + error: { + code: string + message: string + details?: unknown + } +} + +/** + * Out-of-band structured error frame (B5-frame). + * + * Sent for failures that are not scoped to a single in-flight RPC: malformed + * incoming frames, transport-level violations, stream/ws aborts that need a + * typed cause, and gateway-side bookkeeping errors. Replaces the prior + * silent-`catch {}` fallthroughs in client/server/codec so a structured cause + * surfaces back to the peer instead of being logged-only. + * + * `scope` lets the peer route the frame: 'rpc' for an RPC-related failure + * the server could not pin to a call id, 'stream'/'ws' for a body-stream or + * websocket-proxy failure, or 'transport' for protocol-level violations. + * `refId` optionally pins the error to a known stream/ws/rpc id. + */ +export interface WireError { + t: 'error' + scope: 'transport' | 'rpc' | 'stream' | 'ws' + error: { + code: string + message: string + details?: unknown + } + refId?: string | number +} + +/** Event notification (worker โ†’ client) */ +export interface EventMsg { + t: 'event' + topic: string + data: unknown +} + +/** Open a new stream */ +export interface StreamOpen { + t: 'stream.open' + sid: number + meta?: { + contentType?: string + length?: number + } +} + +/** Request bytes from stream (pull-based backpressure) */ +export interface StreamPull { + t: 'stream.pull' + sid: number + creditBytes: number +} + +/** Stream completed successfully */ +export interface StreamEnd { + t: 'stream.end' + sid: number +} + +/** Stream aborted with error */ +export interface StreamAbort { + t: 'stream.abort' + sid: number + error?: string +} + +/** Open WebSocket proxy connection */ +export interface WsOpen { + t: 'ws.open' + wid: number + target: { + binding: string + id: string + url: string + headers?: [string, string][] + } +} + +/** WebSocket proxy opened successfully */ +export interface WsOpened { + t: 'ws.opened' + wid: number +} + +/** Close WebSocket proxy */ +export interface WsClose { + t: 'ws.close' + wid: number + code?: number + reason?: string +} + +/** HTTP upload/download for large files */ +export interface HttpTransfer { + t: 'http.transfer' + id: string + url: string + direction: 'upload' | 'download' +} + +/** Union of all JSON message types */ +export type JsonMsg = + | RpcCall + | RpcOk + | RpcErr + | WireError + | EventMsg + | StreamOpen + | StreamPull + | StreamEnd + | StreamAbort + | WsOpen + | WsOpened + | WsClose + | HttpTransfer + +// ----------------------------------------------------------------------------- +// Data Plane (Binary frames) +// ----------------------------------------------------------------------------- + +/** Binary frame kinds */ +export const BinaryKind = { + StreamChunk: 1, + WsData: 2 +} as const + +export type BinaryKind = typeof BinaryKind[keyof typeof BinaryKind] + +/** Binary frame flags */ +export const BinaryFlags = { + FIN: 0b0001, // Last chunk/frame + TEXT: 0b0010 // Text vs binary (for WS data) +} as const + +/** + * Binary frame header structure: + * - kind: u8 (1 = stream chunk, 2 = ws data) + * - id: u32 (stream id or websocket id) + * - seq: u32 (sequence number for ordering) + * - flags: u8 (FIN, TEXT/BINARY) + * - payload: remaining bytes + * + * Total header size: 10 bytes + */ +export const BINARY_HEADER_SIZE = 10 + +/** Encode a binary frame */ +export function encodeBinaryFrame( + kind: BinaryKind, + id: number, + seq: number, + flags: number, + payload: Uint8Array +): Uint8Array { + const frame = new Uint8Array(BINARY_HEADER_SIZE + payload.byteLength) + const view = new DataView(frame.buffer) + + view.setUint8(0, kind) + view.setUint32(1, id, true) // little-endian + view.setUint32(5, seq, true) + view.setUint8(9, flags) + + frame.set(payload, BINARY_HEADER_SIZE) + + return frame +} + +/** Decoded binary frame */ +export interface DecodedBinaryFrame { + kind: BinaryKind + id: number + seq: number + flags: number + payload: Uint8Array +} + +/** Decode a binary frame */ +export function decodeBinaryFrame(frame: Uint8Array): DecodedBinaryFrame { + if (frame.byteLength < BINARY_HEADER_SIZE) { + throw new Error(`Invalid binary frame: too short (${frame.byteLength} bytes)`) + } + + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) + + return { + kind: view.getUint8(0) as BinaryKind, + id: view.getUint32(1, true), + seq: view.getUint32(5, true), + flags: view.getUint8(9), + payload: frame.subarray(BINARY_HEADER_SIZE) + } +} + +/** Check if FIN flag is set */ +export function isFin(flags: number): boolean { + return (flags & BinaryFlags.FIN) !== 0 +} + +/** Check if TEXT flag is set (vs binary) */ +export function isText(flags: number): boolean { + return (flags & BinaryFlags.TEXT) !== 0 +} + +// ----------------------------------------------------------------------------- +// Message Parsing +// ----------------------------------------------------------------------------- + +/** Parse a JSON message from string */ +export function parseJsonMsg(data: string): JsonMsg { + const msg = JSON.parse(data) as JsonMsg + + // Basic validation + if (typeof msg !== 'object' || msg === null || !('t' in msg)) { + throw new Error('Invalid message: missing type field') + } + + return msg +} + +/** Stringify a JSON message */ +export function stringifyJsonMsg(msg: JsonMsg): string { + return JSON.stringify(msg) +} + +// ----------------------------------------------------------------------------- +// ID Generators +// ----------------------------------------------------------------------------- + +let rpcIdCounter = 0 +let streamIdCounter = 0 +let wsIdCounter = 0 + +/** Generate unique RPC call ID */ +export function nextRpcId(): string { + return `rpc_${++rpcIdCounter}` +} + +/** Generate unique stream ID */ +export function nextStreamId(): number { + return ++streamIdCounter +} + +/** Generate unique WebSocket proxy ID */ +export function nextWsId(): number { + return ++wsIdCounter +} + +/** Reset ID counters (for testing) */ +export function resetIdCounters(): void { + rpcIdCounter = 0 + streamIdCounter = 0 + wsIdCounter = 0 +} + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/** Default chunk size for streaming (256 KB) */ +export const DEFAULT_CHUNK_SIZE = 256 * 1024 + +/** Threshold for switching to HTTP transfer (512 KB โ€” workerd ~1MB WS message limit). */ +export const HTTP_TRANSFER_THRESHOLD = 512 * 1024 + +/** Default WebSocket port for bridge */ +export const DEFAULT_BRIDGE_PORT = 8686 + +/** Default HTTP port for large transfers */ +export const DEFAULT_HTTP_PORT = 8687 diff --git a/packages/devflare/src/bridge/v2/ws-relay.ts b/packages/devflare/src/bridge/v2/ws-relay.ts new file mode 100644 index 0000000..369247a --- /dev/null +++ b/packages/devflare/src/bridge/v2/ws-relay.ts @@ -0,0 +1,232 @@ +๏ปฟ// ============================================================================= +// Transport v2 โ€” WebSocket relay (client side) +// ============================================================================= +// A v2 WS relay multiplexes a virtual WebSocket over the bridge codec. The +// client sends `ws.open`, the server replies with `ws.opened` (or +// `ws.openerr`), then both sides exchange `ws.text` JSON frames for text +// messages and binary kind=2 (`WsData`) frames for binary messages. Either +// side closes by sending `ws.close`. +// ============================================================================= + +import { + TransportV2BinaryFlags, + TransportV2BinaryKind, + encodeTransportV2BinaryFrame, + transportV2IsFin, + transportV2IsText +} from './frames' +import { + parseTransportV2AuxMsg, + stringifyTransportV2AuxMsg +} from './control-messages' +import type { + TransportV2AuxMsg, + TransportV2WsCloseMsg, + TransportV2WsOpenMsg, + TransportV2WsTextMsg +} from './control-messages' +import type { TransportV2Codec } from './codec' +import type { TransportV2DecodedBinaryFrame } from './frames' + +export interface TransportV2WsProxyHandlers { + onMessage?: (data: string | Uint8Array) => void + onClose?: (code?: number, reason?: string) => void + onError?: (error: Error) => void +} + +export interface TransportV2WsProxy { + readonly id: string + readonly opened: Promise + send(data: string | Uint8Array): void + close(code?: number, reason?: string): void + setHandlers(handlers: TransportV2WsProxyHandlers): void +} + +export interface TransportV2WsRelayOptions { + codec: TransportV2Codec + binding: string + path: string + headers?: Record + doId?: string + doName?: string +} + +/** + * Owns all in-flight v2 WS relays multiplexed on a single codec. Wire it up by + * passing its `handleControl`/`handleBinary` to the codec's + * `onUnknownControl`/`onUnknownBinary` hooks. + */ +export class TransportV2WsRelayManager { + #codec: TransportV2Codec + #proxies = new Map() + #nextId = 1 + #nextSeq = new Map() + + constructor(codec: TransportV2Codec) { + this.#codec = codec + } + + open(options: Omit): TransportV2WsProxy { + const id = `v2_ws_${this.#nextId++}` + const proxy = new InternalProxy(id, this) + this.#proxies.set(id, proxy) + + const open: TransportV2WsOpenMsg = { + t: 'ws.open', + id, + binding: options.binding, + path: options.path, + headers: options.headers ?? {} + } + if (options.doId) open.doId = options.doId + if (options.doName) open.doName = options.doName + this.#codec.sendText(stringifyTransportV2AuxMsg(open)) + + return proxy + } + + handleControl(text: string): boolean { + const msg = parseTransportV2AuxMsg(text) + if (!msg) return false + if (!('id' in msg) || typeof msg.id !== 'string') return false + + const proxy = this.#proxies.get(msg.id) + if (!proxy) return msg.t.startsWith('ws.') + + switch (msg.t) { + case 'ws.opened': + proxy._resolveOpened() + return true + case 'ws.openerr': + proxy._rejectOpened(new Error(msg.error.message)) + this.#proxies.delete(proxy.id) + return true + case 'ws.text': + proxy._deliver((msg as TransportV2WsTextMsg).data) + return true + case 'ws.close': + proxy._handleClose((msg as TransportV2WsCloseMsg).code, (msg as TransportV2WsCloseMsg).reason) + this.#proxies.delete(proxy.id) + return true + } + return false + } + + handleBinary(frame: TransportV2DecodedBinaryFrame): boolean { + if (frame.kind !== TransportV2BinaryKind.WsData) return false + const id = `v2_ws_${frame.id}` + const proxy = this.#proxies.get(id) + if (!proxy) return true + + if (transportV2IsText(frame.flags)) { + proxy._deliver(new TextDecoder().decode(frame.payload)) + } else { + proxy._deliver(new Uint8Array(frame.payload)) + } + + if (transportV2IsFin(frame.flags)) { + proxy._handleClose() + this.#proxies.delete(id) + } + return true + } + + _send(proxy: InternalProxy, data: string | Uint8Array): void { + const numericId = Number.parseInt(proxy.id.slice('v2_ws_'.length), 10) + const seq = (this.#nextSeq.get(proxy.id) ?? 0) + 1 + this.#nextSeq.set(proxy.id, seq) + + if (typeof data === 'string') { + const payload = new TextEncoder().encode(data) + const frame = encodeTransportV2BinaryFrame( + TransportV2BinaryKind.WsData, + numericId, + seq, + TransportV2BinaryFlags.TEXT, + payload + ) + this.#codec.sendBinary(frame) + } else { + const frame = encodeTransportV2BinaryFrame( + TransportV2BinaryKind.WsData, + numericId, + seq, + 0, + data + ) + this.#codec.sendBinary(frame) + } + } + + _close(proxy: InternalProxy, code?: number, reason?: string): void { + if (!this.#proxies.has(proxy.id)) return + const close: TransportV2WsCloseMsg = { t: 'ws.close', id: proxy.id } + if (code !== undefined) close.code = code + if (reason !== undefined) close.reason = reason + this.#codec.sendText(stringifyTransportV2AuxMsg(close)) + this.#proxies.delete(proxy.id) + } +} + +class InternalProxy implements TransportV2WsProxy { + readonly id: string + readonly opened: Promise + #openedResolve!: () => void + #openedReject!: (error: Error) => void + #openedSettled = false + #handlers: TransportV2WsProxyHandlers = {} + #manager: TransportV2WsRelayManager + #closed = false + + constructor(id: string, manager: TransportV2WsRelayManager) { + this.id = id + this.#manager = manager + this.opened = new Promise((resolve, reject) => { + this.#openedResolve = resolve + this.#openedReject = reject + }) + } + + setHandlers(handlers: TransportV2WsProxyHandlers): void { + this.#handlers = handlers + } + + send(data: string | Uint8Array): void { + if (this.#closed) throw new Error(`v2 ws ${this.id} already closed`) + this.#manager._send(this, data) + } + + close(code?: number, reason?: string): void { + if (this.#closed) return + this.#closed = true + this.#manager._close(this, code, reason) + this.#handlers.onClose?.(code, reason) + } + + _resolveOpened(): void { + if (this.#openedSettled) return + this.#openedSettled = true + this.#openedResolve() + } + + _rejectOpened(error: Error): void { + if (this.#openedSettled) return + this.#openedSettled = true + this.#openedReject(error) + this.#handlers.onError?.(error) + } + + _deliver(data: string | Uint8Array): void { + this.#handlers.onMessage?.(data) + } + + _handleClose(code?: number, reason?: string): void { + if (this.#closed) return + this.#closed = true + this.#handlers.onClose?.(code, reason) + } +} + +export function isTransportV2WsAuxMsg(msg: TransportV2AuxMsg): boolean { + return msg.t.startsWith('ws.') +} diff --git a/packages/devflare/src/browser-shim/binding-worker.ts b/packages/devflare/src/browser-shim/binding-worker.ts new file mode 100644 index 0000000..48319c2 --- /dev/null +++ b/packages/devflare/src/browser-shim/binding-worker.ts @@ -0,0 +1,339 @@ +// ============================================================================= +// Browser Binding Worker โ€” Runs inside workerd for WebSocket support +// ============================================================================= +// This worker acts as the BROWSER binding inside workerd. +// It proxies HTTP requests to the browser shim server and handles WebSocket +// connections using WebSocketPair to properly support @cloudflare/puppeteer. +// +// Flow: +// 1. puppeteer.launch() โ†’ GET /v1/acquire โ†’ proxy to browser shim โ†’ get sessionId +// 2. puppeteer.connect() โ†’ GET /v1/connectDevtools?browser_session=X +// โ†’ Create WebSocketPair, connect to Chrome's DevTools endpoint via shim +// โ†’ Return Response with webSocket property (Cloudflare style) +// +// The browser shim server provides: +// - POST/GET /v1/acquire โ†’ Launch browser, return sessionId +// - GET /v1/session/:sessionId โ†’ Get session info including wsEndpoint +// - GET /v1/sessions โ†’ List active sessions +// - GET /v1/limits โ†’ Return limits info +// - GET /v1/history โ†’ Return session history +// +// CRITICAL: @cloudflare/puppeteer uses a multi-chunk framing protocol: +// - First chunk: 4-byte little-endian length header + payload slice +// - Subsequent chunks: raw payload slices (no header) +// - Max chunk size: 1048575 bytes (just under 1MB Workers limit) +// - Must reassemble chunks before forwarding to Chrome +// - Must split Chrome responses into chunks for puppeteer +// ============================================================================= + +// Max chunk size for WebSocket messages (Workers limit is ~1MB, leave room) +const MAX_CHUNK_SIZE = 1048575 + +/** + * Generate the browser binding worker script + * @param browserShimUrl - URL of the external browser shim server (e.g., http://127.0.0.1:8788) + * @param debug - Enable debug logging (default: false) + */ +export function getBrowserBindingScript(browserShimUrl: string, debug = false): string { + // Safely encode the URL for injection + const safeUrl = JSON.stringify(browserShimUrl) + + return ` +// Browser Binding Worker โ€” Proxies puppeteer requests to external browser shim +// Handles WebSocket upgrades using WebSocketPair for @cloudflare/puppeteer compatibility + +const BROWSER_SHIM_URL = ${safeUrl} +const MAX_CHUNK_SIZE = ${MAX_CHUNK_SIZE} +const DEBUG = ${debug} +const log = (...args) => DEBUG && console.log('[BrowserBinding]', ...args) + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url) + const upgradeHeader = request.headers.get('Upgrade') + const isWebSocket = upgradeHeader && upgradeHeader.toLowerCase() === 'websocket' + + log('Request:', url.pathname, isWebSocket ? '(WebSocket)' : '(HTTP)') + + // Handle WebSocket upgrade for DevTools connection + if (url.pathname === '/v1/connectDevtools' && isWebSocket) { + return handleDevToolsWebSocket(request, url) + } + + // Proxy all other requests to the browser shim server + return proxyToBrowserShim(request, url) + } +} + +// Proxy HTTP requests to the external browser shim server +async function proxyToBrowserShim(request, url) { + const shimUrl = new URL(url.pathname + url.search, BROWSER_SHIM_URL) + + log('Proxying to:', shimUrl.toString()) + + const response = await fetch(shimUrl.toString(), { + method: request.method, + headers: request.headers, + body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined + }) + + log('Response:', response.status) + + // Return the response as-is + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }) +} + +// Validate WebSocket close code to be in valid range +function validateCloseCode(code) { + if (typeof code !== 'number' || isNaN(code)) return 1000 + if (code < 1000 || code > 4999) return 1000 + return code +} + +// Split a message into chunks following @cloudflare/puppeteer protocol +// First chunk has 4-byte LE length header, subsequent chunks are raw payload +function messageToChunks(message) { + const data = typeof message === 'string' + ? new TextEncoder().encode(message) + : new Uint8Array(message) + + const chunks = [] + const totalLength = data.length + let offset = 0 + let isFirst = true + + while (offset < totalLength) { + const remaining = totalLength - offset + let chunkSize + + if (isFirst) { + // First chunk: 4-byte header + payload + chunkSize = Math.min(remaining, MAX_CHUNK_SIZE - 4) + const chunk = new Uint8Array(chunkSize + 4) + new DataView(chunk.buffer).setUint32(0, totalLength, true) // little-endian + chunk.set(data.subarray(offset, offset + chunkSize), 4) + chunks.push(chunk) + isFirst = false + } else { + // Subsequent chunks: raw payload only + chunkSize = Math.min(remaining, MAX_CHUNK_SIZE) + const chunk = data.subarray(offset, offset + chunkSize) + chunks.push(chunk) + } + + offset += chunkSize + } + + return chunks +} + +// Reassemble chunks back into a complete message +// Returns null if more chunks are needed +function chunksToMessage(chunks) { + if (chunks.length === 0) return null + + // First chunk must have 4-byte header + const firstChunk = chunks[0] + if (firstChunk.length < 4) return null + + const expectedLength = new DataView(firstChunk.buffer, firstChunk.byteOffset).getUint32(0, true) + + // Calculate total received payload + let totalReceived = firstChunk.length - 4 // first chunk payload (minus header) + for (let i = 1; i < chunks.length; i++) { + totalReceived += chunks[i].length + } + + if (totalReceived < expectedLength) { + return null // Need more chunks + } + + // Reassemble the message + const assembled = new Uint8Array(expectedLength) + let offset = 0 + + // Copy first chunk payload (skip 4-byte header) + const firstPayload = firstChunk.subarray(4) + assembled.set(firstPayload, offset) + offset += firstPayload.length + + // Copy remaining chunks + for (let i = 1; i < chunks.length; i++) { + const chunk = chunks[i] + const toCopy = Math.min(chunk.length, expectedLength - offset) + assembled.set(chunk.subarray(0, toCopy), offset) + offset += toCopy + } + + return new TextDecoder().decode(assembled) +} + +// Handle WebSocket upgrade for DevTools connection +// Creates a WebSocketPair and proxies to Chrome's DevTools WebSocket +async function handleDevToolsWebSocket(request, url) { + const sessionId = url.searchParams.get('browser_session') + if (!sessionId) { + return new Response('browser_session parameter required', { status: 400 }) + } + + log('DevTools WebSocket request for session:', sessionId) + + // Get session info from browser shim (includes Chrome's wsEndpoint) + const sessionUrl = new URL('/v1/session/' + sessionId, BROWSER_SHIM_URL) + + // Add timeout for session fetch + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + let sessionRes + try { + sessionRes = await fetch(sessionUrl.toString(), { signal: controller.signal }) + } catch (e) { + DEBUG && console.error('[BrowserBinding] Session fetch timeout or error:', e.message) + return new Response('Session fetch timeout', { status: 504 }) + } finally { + clearTimeout(timeout) + } + + if (!sessionRes.ok) { + DEBUG && console.error('[BrowserBinding] Session not found:', sessionId) + return new Response('Session not found', { status: 404 }) + } + + const sessionInfo = await sessionRes.json() + const wsEndpoint = sessionInfo.wsEndpoint + + if (!wsEndpoint) { + DEBUG && console.error('[BrowserBinding] No wsEndpoint in session info') + return new Response('No wsEndpoint for session', { status: 500 }) + } + + log('Connecting to Chrome DevTools:', wsEndpoint) + + // Connect to Chrome's DevTools WebSocket + // Chrome uses ws:// but fetch expects http:// for WebSocket upgrade + const chromeUrl = wsEndpoint.replace('ws://', 'http://').replace('wss://', 'https://') + + const chromeRes = await fetch(chromeUrl, { + headers: { Upgrade: 'websocket' } + }) + + if (!chromeRes.webSocket) { + DEBUG && console.error('[BrowserBinding] Failed to connect to Chrome DevTools') + return new Response('Failed to connect to Chrome DevTools', { status: 502 }) + } + + const chromeWs = chromeRes.webSocket + chromeWs.accept() + + log('Connected to Chrome DevTools') + + // Create WebSocketPair for client connection + const { 0: client, 1: server } = new WebSocketPair() + server.accept() + + // Chunk buffer for reassembling multi-chunk messages from puppeteer + let chunks = [] + const MAX_BUFFER_SIZE = 50 * 1024 * 1024 // 50MB max buffer + let bufferSize = 0 + + // Proxy messages from client (puppeteer) to Chrome + // Handle multi-chunk framing protocol + server.addEventListener('message', (event) => { + // Keep-alive ping from puppeteer + if (event.data === 'ping') { + return + } + + // Handle binary data (chunked protocol) + if (event.data instanceof ArrayBuffer) { + const chunk = new Uint8Array(event.data) + bufferSize += chunk.length + + // Prevent unbounded buffering + if (bufferSize > MAX_BUFFER_SIZE) { + DEBUG && console.error('[BrowserBinding] Buffer overflow, closing connection') + server.close(1009, 'Message too big') + chromeWs.close(1009, 'Message too big') + return + } + + chunks.push(chunk) + + // Try to reassemble complete message + const message = chunksToMessage(chunks) + if (message !== null) { + // Send complete message to Chrome + if (chromeWs.readyState === 1) { // OPEN + chromeWs.send(message) + } + // Clear buffer + chunks = [] + bufferSize = 0 + } + } else if (typeof event.data === 'string') { + // Shouldn't happen in normal protocol, but handle it + if (chromeWs.readyState === 1) { + chromeWs.send(event.data) + } + } + }) + + // Proxy messages from Chrome to client (puppeteer) + // Split into chunks following the multi-chunk protocol + chromeWs.addEventListener('message', (event) => { + if (server.readyState !== 1) return // Not OPEN + + // Split message into chunks + const outChunks = messageToChunks(event.data) + for (const chunk of outChunks) { + server.send(chunk) + } + }) + + // Handle close events with validated codes + server.addEventListener('close', (event) => { + log('Client WebSocket closed:', event.code) + const code = validateCloseCode(event.code) + try { + if (chromeWs.readyState === 1 || chromeWs.readyState === 0) { + chromeWs.close(code, event.reason || '') + } + } catch {} + }) + + chromeWs.addEventListener('close', (event) => { + log('Chrome WebSocket closed:', event.code) + const code = validateCloseCode(event.code) + try { + if (server.readyState === 1 || server.readyState === 0) { + server.close(code, event.reason || '') + } + } catch {} + }) + + // Handle errors + server.addEventListener('error', (event) => { + DEBUG && console.error('[BrowserBinding] Client WebSocket error') + try { chromeWs.close(1011, 'Client error') } catch {} + }) + + chromeWs.addEventListener('error', (event) => { + DEBUG && console.error('[BrowserBinding] Chrome WebSocket error') + try { server.close(1011, 'Chrome error') } catch {} + }) + + log('WebSocket proxy established') + + // Return Cloudflare-style WebSocket response + return new Response(null, { + status: 101, + webSocket: client + }) +} +` +} diff --git a/packages/devflare/src/browser-shim/handler.ts b/packages/devflare/src/browser-shim/handler.ts new file mode 100644 index 0000000..1822f9d --- /dev/null +++ b/packages/devflare/src/browser-shim/handler.ts @@ -0,0 +1,274 @@ +// ============================================================================= +// Browser Rendering Handler โ€” Service binding handler for BROWSER +// ============================================================================= +// This handler runs as a Miniflare service binding custom fetch handler. +// It proxies requests to the browser shim server, which: +// - Launches Chrome instances on demand +// - Manages browser sessions +// - Provides WebSocket proxy to Chrome DevTools +// +// Why this exists: +// - workerd's fetch() cannot make outgoing WebSocket connections to external servers +// - Using a browser worker inside Miniflare fails for WebSocket DevTools connections +// - Miniflare's custom fetch handler runs in Node.js with full networking +// +// WebSocket Architecture: +// - For WebSocket upgrade requests, we use Miniflare's WebSocketPair and coupleWebSocket +// - These APIs allow us to create a WebSocket pair, couple one end to a Node.js ws connection, +// and return the other end in the Response for workerd to use +// ============================================================================= + +import type { Miniflare } from 'miniflare' +import type { ConsolaInstance } from 'consola' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface BrowserNodeHandlerOptions { + /** URL of the browser shim server (e.g., http://127.0.0.1:8788) */ + browserShimUrl: string + /** Logger instance */ + logger?: ConsolaInstance + /** Enable verbose logging */ + verbose?: boolean +} + +// WebSocket types from Miniflare (will be imported dynamically) +type WebSocketPair = [WebSocket, WebSocket] +type CoupleWebSocket = (ws: import('ws').WebSocket, pair: WebSocket) => Promise + +// Cache for dynamically imported modules +let cachedWebSocketPair: (new () => { 0: WebSocket; 1: WebSocket }) | null = null +let cachedCoupleWebSocket: CoupleWebSocket | null = null +let cachedResponse: typeof Response | null = null + +/** + * Lazily import Miniflare's WebSocket utilities + * These are the same utilities Miniflare uses for upgradingFetch + */ +async function getWebSocketUtils() { + if (!cachedWebSocketPair || !cachedCoupleWebSocket || !cachedResponse) { + // Import from miniflare package - it re-exports from @miniflare/web-sockets + const miniflare = await import('miniflare') + cachedWebSocketPair = (miniflare as any).WebSocketPair + cachedCoupleWebSocket = (miniflare as any).coupleWebSocket + cachedResponse = (miniflare as any).Response + } + return { + WebSocketPair: cachedWebSocketPair!, + coupleWebSocket: cachedCoupleWebSocket!, + MfResponse: cachedResponse! + } +} + +// ----------------------------------------------------------------------------- +// Handler Factory +// ----------------------------------------------------------------------------- + +/** + * Create a service binding handler for browser rendering + * + * This handler is passed to Miniflare's serviceBindings option directly as a function. + * It uses the fetch-style signature: (request: Request, miniflare: Miniflare) => Response + * + * @param options - Handler configuration + * @returns Fetch-style handler function + */ +export function createBrowserNodeHandler(options: BrowserNodeHandlerOptions) { + const { browserShimUrl, logger, verbose } = options + + return async function browserHandler( + request: Request, + _miniflare: Miniflare + ): Promise { + const url = new URL(request.url) + const targetUrl = browserShimUrl + url.pathname + url.search + + if (verbose) { + logger?.debug(`[BrowserHandler] ${request.method} ${url.pathname}${url.search}`) + } + + // Check if this is a WebSocket upgrade request + const upgradeHeader = request.headers.get('upgrade') + if (upgradeHeader?.toLowerCase() === 'websocket') { + return await handleWebSocketUpgrade(url, browserShimUrl, logger, verbose) + } + + // Handle HTTP requests by proxying to the browser shim + return await handleHttpRequest(request, targetUrl, logger, verbose) + } +} + +// ----------------------------------------------------------------------------- +// HTTP Request Handling +// ----------------------------------------------------------------------------- + +async function handleHttpRequest( + request: Request, + targetUrl: string, + logger?: ConsolaInstance, + verbose?: boolean +): Promise { + const hopByHopHeaders = new Set([ + 'connection', + 'host', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length' + ]) + + if (verbose) { + logger?.debug(`[BrowserHandler] Proxying HTTP to: ${targetUrl}`) + } + + try { + const forwardedHeaders = new Headers() + request.headers.forEach((value, key) => { + if (!hopByHopHeaders.has(key.toLowerCase())) { + forwardedHeaders.set(key, value) + } + }) + + const proxyRequest: RequestInit & { duplex?: 'half' } = { + method: request.method, + headers: forwardedHeaders + } + + if (request.body && request.method !== 'GET' && request.method !== 'HEAD') { + proxyRequest.body = request.body + proxyRequest.duplex = 'half' + } + + // Proxy request to browser shim + const response = await fetch(targetUrl, proxyRequest) + + // Return the response directly + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }) + } catch (error) { + const msg = error instanceof Error ? error.message : 'Proxy error' + logger?.error(`[BrowserHandler] HTTP proxy error: ${msg}`) + return new Response(JSON.stringify({ error: msg }), { + status: 502, + headers: { 'Content-Type': 'application/json' } + }) + } +} + +// ----------------------------------------------------------------------------- +// WebSocket Upgrade Handling +// ----------------------------------------------------------------------------- + +/** + * Handle WebSocket upgrade requests for DevTools protocol + * + * This function: + * 1. Connects to the browser shim's WebSocket endpoint using Node.js ws library + * 2. Creates a Miniflare WebSocketPair + * 3. Couples the Node.js ws with one end of the pair (for relaying messages) + * 4. Returns a 101 Response with the other end as `webSocket` property + * + * This allows workerd to communicate with Chrome DevTools through the relay. + */ +async function handleWebSocketUpgrade( + url: URL, + browserShimUrl: string, + logger?: ConsolaInstance, + verbose?: boolean +): Promise { + // Get session ID from query params + const sessionId = url.searchParams.get('browser_session') + if (!sessionId) { + return new Response('Missing browser_session parameter', { status: 400 }) + } + + if (verbose) { + logger?.debug(`[BrowserHandler] WebSocket upgrade for session: ${sessionId}`) + } + + let shimWs: import('ws').WebSocket | null = null + + try { + // Import ws library and Miniflare WebSocket utilities + const { WebSocket: WsWebSocket } = await import('ws') + const { WebSocketPair, coupleWebSocket, MfResponse } = await getWebSocketUtils() + + // Build target WebSocket URL + const targetWsUrl = browserShimUrl.replace('http://', 'ws://') + url.pathname + url.search + + if (verbose) { + logger?.debug(`[BrowserHandler] Connecting to browser shim WebSocket: ${targetWsUrl}`) + } + + // Connect to browser shim WebSocket + shimWs = new WsWebSocket(targetWsUrl) + const browserShimSocket = shimWs + + // Wait for connection to open + await new Promise((resolve, reject) => { + const connectTimeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, 10000) + + browserShimSocket.once('open', () => { + clearTimeout(connectTimeout) + if (verbose) { + logger?.debug(`[BrowserHandler] WebSocket connection opened to shim`) + } + resolve() + }) + browserShimSocket.once('error', (err) => { + clearTimeout(connectTimeout) + logger?.error(`[BrowserHandler] WebSocket connection error: ${err.message}`) + reject(err) + }) + }) + + // Create a WebSocketPair - this is Miniflare's implementation + // that works in Node.js and can be returned to workerd + // IMPORTANT: The order is [worker, client] - worker is returned in response, + // client is coupled to the external WebSocket connection + const { 0: worker, 1: client } = new WebSocketPair() + + if (verbose) { + logger?.debug(`[BrowserHandler] WebSocketPair created, MfResponse type: ${MfResponse?.name || typeof MfResponse}`) + } + + // Couple the Node.js ws (to browser shim) with the client end of the pair + // This sets up bidirectional message relaying: + // - Messages from shimWs โ†’ client โ†’ (through pair) โ†’ worker โ†’ workerd + // - Messages from workerd โ†’ worker โ†’ (through pair) โ†’ client โ†’ shimWs + await coupleWebSocket(browserShimSocket, client) + + if (verbose) { + logger?.debug(`[BrowserHandler] WebSocket coupled successfully, returning 101 response`) + } + + // Return a 101 Switching Protocols response with the worker end of the pair + // Use Miniflare's Response class which accepts webSocket in the init object + logger?.info(`[BrowserHandler] Creating 101 response with MfResponse: ${MfResponse?.name}`) + const response = new MfResponse(null, { + status: 101, + webSocket: worker + } as any) + logger?.info(`[BrowserHandler] 101 response created, status: ${response.status}`) + return response as Response + } catch (error) { + try { + shimWs?.close() + } catch {} + + const msg = error instanceof Error ? error.message : 'WebSocket error' + logger?.error(`[BrowserHandler] WebSocket upgrade error: ${msg}`) + return new Response(`WebSocket upgrade failed: ${msg}`, { status: 500 }) + } +} diff --git a/packages/devflare/src/browser-shim/index.ts b/packages/devflare/src/browser-shim/index.ts new file mode 100644 index 0000000..b828e9f --- /dev/null +++ b/packages/devflare/src/browser-shim/index.ts @@ -0,0 +1,12 @@ +// ============================================================================= +// Browser Rendering Shim โ€” Local Puppeteer Bridge +// ============================================================================= +// Emulates Cloudflare's Browser Rendering binding locally by running +// a real Puppeteer/Chrome instance and exposing it via HTTP/WebSocket. +// +// This allows `@cloudflare/puppeteer` to connect to a local browser just like +// it would to Cloudflare's Browser Rendering service. +// ============================================================================= + +export { createBrowserShim, type BrowserShimOptions, type BrowserShim } from './server' +export { createBrowserNodeHandler, type BrowserNodeHandlerOptions } from './handler' diff --git a/packages/devflare/src/browser-shim/server.ts b/packages/devflare/src/browser-shim/server.ts new file mode 100644 index 0000000..2a6ff0c --- /dev/null +++ b/packages/devflare/src/browser-shim/server.ts @@ -0,0 +1,843 @@ +// ============================================================================= +// Browser Shim Server โ€” HTTP/WebSocket server for local Browser Rendering +// ============================================================================= +/** + * Devflare's **local browser-rendering shim**. + * + * Accepts only loopback browser origins (e.g. `http://127.0.0.1:*`, + * `http://localhost:*`) plus origin-less tool traffic (Puppeteer, curl, and + * other non-browser clients that do not send an `Origin` header). Cross-origin + * browser traffic is rejected at the request boundary. + * + * This is NOT a user-facing app route โ€” it is devflare's protected helper + * endpoint used by the local Browser Rendering binding to satisfy the + * `@cloudflare/puppeteer` contract during local dev. The loopback-only posture + * applies to this shim only and does not apply to the user's normal worker + * routes. + */ +// Provides endpoints that @cloudflare/puppeteer expects: +// - POST /v1/acquire โ†’ Launch browser, return sessionId +// - GET /v1/connectDevtools?browser_session=X โ†’ WebSocket to Chrome DevTools +// - GET /v1/sessions โ†’ List active sessions +// - GET /v1/limits โ†’ Return limits info +// - GET /v1/history โ†’ Return session history +// +// Auto-installs Chrome Headless Shell using @puppeteer/browsers +// Works with both Node.js and Bun runtimes +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { createServer, type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http' +import puppeteerCore, { type Browser } from 'puppeteer-core' +import { + install, + resolveBuildId, + detectBrowserPlatform, + Browser as BrowserType +} from '@puppeteer/browsers' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface BrowserShimOptions { + /** Port to run the shim server on (default: 8788) */ + port?: number + /** Host to bind to (default: 127.0.0.1) */ + host?: string + /** Logger instance */ + logger?: ConsolaInstance + /** Enable verbose logging */ + verbose?: boolean + /** Keep alive timeout in ms (default: 60000 = 1 minute) */ + keepAlive?: number + /** Custom cache directory for Chrome (default: ~/.devflare/chrome) */ + cacheDir?: string + /** + * Opt-in to launching Chrome with `--no-sandbox` / `--disable-setuid-sandbox`. + * + * Disabling the Chromium sandbox is a significant security regression: a + * compromised page can access the host with the privileges of the process + * running the browser. Only enable this in trusted CI containers or rootless + * environments where the sandbox cannot start. Defaults to `false`. + */ + allowNoSandbox?: boolean +} + +export interface BrowserShim { + /** Start the browser shim server */ + start(): Promise + /** Stop the server and close all browsers */ + stop(): Promise + /** Get the server URL (for creating Fetcher) */ + getUrl(): string +} + +interface BrowserSession { + sessionId: string + browser: Browser + wsEndpoint: string + connectionId?: string + connectionStartTime?: number + startTime: number + idleTimeout?: ReturnType +} + +interface ClosedSession { + sessionId: string + startTime: number + endTime: number + closeReason: number + closeReasonText: string +} + +// Cached browser executable path +let cachedExecutablePath: string | null = null + +// ----------------------------------------------------------------------------- +// Chrome launch flags +// ----------------------------------------------------------------------------- + +/** + * Default Chrome flags used when launching headless Chrome for local + * browser-rendering emulation. Each flag is included for a deliberate reason; + * edit cautiously. + * + * NOTE: `--no-sandbox` / `--disable-setuid-sandbox` are intentionally NOT part + * of the defaults. Disabling the sandbox removes the primary boundary between + * untrusted web content and the host and must be opted into explicitly via + * `BrowserShimOptions.allowNoSandbox`. + */ +export const DEFAULT_CHROME_FLAGS: readonly string[] = [ + // Avoid /dev/shm exhaustion in small containers (common on CI). + '--disable-dev-shm-usage', + // Headless shell has no GPU; skip GL init to avoid startup errors. + '--disable-gpu', + '--disable-software-rasterizer', + // Trim background/extension surface that complex test pages don't need. + '--disable-extensions', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-features=TranslateUI', + '--disable-ipc-flooding-protection', + // Reduce resource usage during automated runs. + '--disable-default-apps', + '--mute-audio', + // Prevent OOM on memory-heavy pages inside constrained runners. + '--js-flags=--max-old-space-size=4096' +] + +/** + * Flags appended only when `allowNoSandbox` is explicitly enabled. Kept in a + * separate constant so callers and tests can assert they are opt-in. + */ +export const NO_SANDBOX_FLAGS: readonly string[] = [ + '--no-sandbox', + '--disable-setuid-sandbox' +] + +/** + * Resolve the Chrome argv for a shim launch. Exported for testability. + */ +export function resolveChromeFlags(options: { allowNoSandbox?: boolean } = {}): string[] { + const flags = [...DEFAULT_CHROME_FLAGS] + if (options.allowNoSandbox) { + flags.unshift(...NO_SANDBOX_FLAGS) + } + return flags +} + +// ----------------------------------------------------------------------------- +// Download progress tracker +// ----------------------------------------------------------------------------- + +export interface DownloadProgress { + bytesReceived: number + totalBytes: number +} + +/** + * Create a download progress logger that emits at most one "start" line and + * exactly one "complete" line per download. Avoids the previous heuristic + * percent-spam which could log the same bucket multiple times. + * + * The returned callback matches `@puppeteer/browsers` `downloadProgressCallback`. + */ +export function createDownloadProgressLogger( + logger?: ConsolaInstance, + label: string = 'Chrome' +): { + onProgress: (downloadedBytes: number, totalBytes: number) => void + finalize: () => void + readonly progress: DownloadProgress + readonly started: boolean + readonly completed: boolean +} { + const state: { started: boolean; completed: boolean; progress: DownloadProgress } = { + started: false, + completed: false, + progress: { bytesReceived: 0, totalBytes: 0 } + } + + return { + onProgress(downloadedBytes: number, totalBytes: number) { + if (state.completed) return + + state.progress.bytesReceived = downloadedBytes + state.progress.totalBytes = totalBytes + + if (!state.started) { + state.started = true + logger?.info(`[BrowserShim] Downloading ${label}...`) + } + + if (totalBytes > 0 && downloadedBytes >= totalBytes) { + state.completed = true + logger?.info(`[BrowserShim] ${label} download complete`) + } + }, + /** + * Emit the single "complete" line if a download was started but the + * progress stream never reported final totals. No-op if the download + * never started (e.g. fully-cached build) or already completed. + */ + finalize() { + if (!state.started || state.completed) return + state.completed = true + logger?.info(`[BrowserShim] ${label} download complete`) + }, + get progress() { + return state.progress + }, + get started() { + return state.started + }, + get completed() { + return state.completed + } + } +} + +// ----------------------------------------------------------------------------- +// Browser Installation +// ----------------------------------------------------------------------------- + +/** + * Get or install Chrome Headless Shell + * Uses a shared cache directory so Chrome is only installed once globally + */ +async function ensureChrome( + cacheDir: string, + logger?: ConsolaInstance +): Promise { + // Return cached path if already resolved + if (cachedExecutablePath && existsSync(cachedExecutablePath)) { + return cachedExecutablePath + } + + const platform = detectBrowserPlatform() + if (!platform) { + throw new Error('Could not detect browser platform') + } + + // Resolve latest stable build ID for Chrome Headless Shell + const buildId = await resolveBuildId( + BrowserType.CHROMEHEADLESSSHELL, + platform, + 'stable' + ) + + logger?.debug(`[BrowserShim] Resolved Chrome Headless Shell build: ${buildId}`) + + const progressLogger = createDownloadProgressLogger(logger, 'Chrome') + + // Install Chrome Headless Shell if not present + const installedBrowser = await install({ + browser: BrowserType.CHROMEHEADLESSSHELL, + buildId, + cacheDir, + downloadProgressCallback: (downloadedBytes, totalBytes) => { + progressLogger.onProgress(downloadedBytes, totalBytes) + } + }) + + // Fallback: if a download started but progress events never reported final + // totals, emit the single "complete" line so logs are not dangling. No-op + // when the build was already cached (nothing was downloaded). + progressLogger.finalize() + + cachedExecutablePath = installedBrowser.executablePath + logger?.success(`[BrowserShim] Chrome ready: ${installedBrowser.executablePath}`) + + return installedBrowser.executablePath +} + +// ----------------------------------------------------------------------------- +// Browser Shim Server Implementation (Node.js compatible) +// ----------------------------------------------------------------------------- + +export function createBrowserShim(options: BrowserShimOptions = {}): BrowserShim { + const { + port = 8788, + host = '127.0.0.1', + logger, + verbose = false, + keepAlive = 60000, + cacheDir = join(homedir(), '.devflare', 'chrome'), + allowNoSandbox = false + } = options + + const chromeLaunchArgs = resolveChromeFlags({ allowNoSandbox }) + if (allowNoSandbox) { + logger?.warn( + '[BrowserShim] Launching Chrome with --no-sandbox (allowNoSandbox=true). ' + + 'Only use this in trusted CI/rootless environments.' + ) + } + + let server: HttpServer | null = null + let executablePath: string | null = null + const sessions = new Map() + const history: ClosedSession[] = [] + + // Dynamic import of ws package (may not be installed) + let WebSocketServerClass: any = null + let WebSocketClass: any = null + const maxRequestBodyBytes = 1024 * 1024 + + function getRequestOrigin(req: IncomingMessage): string | null { + const origin = req.headers.origin + if (typeof origin === 'string') { + return origin + } + + if (Array.isArray(origin) && origin[0]) { + return origin[0] + } + + return null + } + + function isLoopbackOrigin(origin: string): boolean { + try { + const url = new URL(origin) + return url.hostname === '127.0.0.1' + || url.hostname === 'localhost' + || url.hostname === '::1' + || url.hostname === '[::1]' + } catch { + return false + } + } + + function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean { + const origin = getRequestOrigin(req) + if (!origin) { + return true + } + + if (!isLoopbackOrigin(origin)) { + res.writeHead(403, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Forbidden origin' })) + return false + } + + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + res.setHeader('Vary', 'Origin') + return true + } + + /** + * Launch a new browser and create a session + */ + async function acquireSession(acquireOptions?: { + keep_alive?: number + }): Promise<{ sessionId: string }> { + if (!executablePath) { + throw new Error('Chrome not initialized') + } + + // Launch browser with remote debugging enabled + // Additional flags for stability with complex pages + const browser = await puppeteerCore.launch({ + executablePath, + headless: true, + // Increase protocol timeout for complex pages + protocolTimeout: 120000, + args: chromeLaunchArgs + }) + + const wsEndpoint = browser.wsEndpoint() + const sessionId = crypto.randomUUID() + + const session: BrowserSession = { + sessionId, + browser, + wsEndpoint, + startTime: Date.now() + } + + sessions.set(sessionId, session) + + // Set up idle timeout + const timeout = acquireOptions?.keep_alive ?? keepAlive + if (timeout > 0) { + session.idleTimeout = setTimeout(async () => { + const s = sessions.get(sessionId) + if (s && !s.connectionId) { + // No active connection, close browser + await closeSession(sessionId, 2, 'BrowserIdle') + } + }, timeout) + } + + if (verbose) { + logger?.debug(`[BrowserShim] Acquired session ${sessionId}`) + } + + return { sessionId } + } + + /** + * Close a browser session + */ + async function closeSession( + sessionId: string, + closeReason: number = 1, + closeReasonText: string = 'NormalClosure' + ): Promise { + const session = sessions.get(sessionId) + if (!session) return + + // Clear idle timeout + if (session.idleTimeout) { + clearTimeout(session.idleTimeout) + } + + try { + await session.browser.close() + } catch { + // Ignore errors closing browser + } + + sessions.delete(sessionId) + + // Add to history + history.unshift({ + sessionId, + startTime: session.startTime, + endTime: Date.now(), + closeReason, + closeReasonText + }) + + // Keep only last 100 entries + if (history.length > 100) { + history.pop() + } + + if (verbose) { + logger?.debug(`[BrowserShim] Closed session ${sessionId}: ${closeReasonText}`) + } + } + + /** + * Handle HTTP requests + */ + async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url || '/', `http://${host}:${port}`) + const method = req.method || 'GET' + + // Always log incoming requests for debugging + logger?.debug(`[BrowserShim] ${method} ${url.pathname}${url.search ? url.search : ''}`) + + if (!applyCorsHeaders(req, res)) { + return + } + + if (method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + // POST /v1/acquire - Launch a new browser + // Note: @cloudflare/puppeteer actually uses GET (with query params) for acquire + if (url.pathname === '/v1/acquire' && (method === 'POST' || method === 'GET')) { + try { + let acquireOptions: { keep_alive?: number } = {} + // Parse query params for GET requests (used by @cloudflare/puppeteer) + if (method === 'GET') { + const keepAlive = url.searchParams.get('keep_alive') + if (keepAlive) { + acquireOptions.keep_alive = parseInt(keepAlive, 10) + } + } else { + // Parse body for POST requests + try { + const body = await readBody(req) + acquireOptions = JSON.parse(body) as { keep_alive?: number } + } catch { + // Ignore JSON parse errors + } + } + const result = await acquireSession(acquireOptions) + sendJson(res, 200, result) + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to acquire browser' + logger?.error(`[BrowserShim] Acquire failed: ${msg}`) + sendJson(res, 500, { error: msg }) + } + return + } + + // GET /v1/sessions - List active sessions + if (url.pathname === '/v1/sessions' && method === 'GET') { + const activeSessions = Array.from(sessions.values()).map((s) => ({ + sessionId: s.sessionId, + startTime: s.startTime, + connectionId: s.connectionId, + connectionStartTime: s.connectionStartTime + })) + sendJson(res, 200, activeSessions) + return + } + + // GET /v1/history - List recent sessions + if (url.pathname === '/v1/history' && method === 'GET') { + sendJson(res, 200, history.slice(0, 50)) + return + } + + // GET /v1/limits - Return limits info + if (url.pathname === '/v1/limits' && method === 'GET') { + sendJson(res, 200, { + activeSessions: Array.from(sessions.keys()).map((id) => ({ id })), + allowedBrowserAcquisitions: 10, + maxConcurrentSessions: 10, + timeUntilNextAllowedBrowserAcquisition: 0 + }) + return + } + + // GET /v1/session/:sessionId - Get session info including wsEndpoint + // This is used by the browser rendering worker to connect to Chrome directly + if (url.pathname.startsWith('/v1/session/') && method === 'GET') { + const sessionId = url.pathname.slice('/v1/session/'.length) + const session = sessions.get(sessionId) + if (!session) { + sendJson(res, 404, { error: 'Session not found' }) + return + } + sendJson(res, 200, { + sessionId: session.sessionId, + wsEndpoint: session.wsEndpoint, + startTime: session.startTime, + connectionId: session.connectionId, + connectionStartTime: session.connectionStartTime + }) + return + } + + // Health check + if (url.pathname === '/_devflare/browser/health') { + sendJson(res, 200, { + ok: true, + activeSessions: sessions.size, + historySize: history.length, + executablePath + }) + return + } + + // For WebSocket upgrade requests, the upgrade handler handles it + if (url.pathname === '/v1/connectDevtools') { + // Will be handled by WebSocket server upgrade + res.writeHead(426, { 'Content-Type': 'text/plain' }) + res.end('WebSocket upgrade required') + return + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + } + + /** + * Read request body as string + */ + function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let totalBytes = 0 + + req.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (totalBytes > maxRequestBodyBytes) { + req.destroy() + reject(new Error(`Request body exceeds ${maxRequestBodyBytes} bytes`)) + return + } + + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString())) + req.on('error', reject) + }) + } + + /** + * Send JSON response + */ + function sendJson(res: ServerResponse, status: number, data: unknown): void { + const body = JSON.stringify(data) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }) + res.end(body) + } + + /** + * Start the browser shim server + */ + async function start(): Promise { + // Ensure Chrome is installed + logger?.info('[BrowserShim] Ensuring Chrome Headless Shell is available...') + executablePath = await ensureChrome(cacheDir, logger) + + // Try to dynamically import ws package + try { + const wsModule = await import('ws') as unknown as { + WebSocketServer?: typeof import('ws').WebSocketServer + WebSocket?: typeof import('ws').WebSocket + default?: { + WebSocketServer?: typeof import('ws').WebSocketServer + WebSocket?: typeof import('ws').WebSocket + } + } + WebSocketServerClass = wsModule.WebSocketServer || wsModule.default?.WebSocketServer + WebSocketClass = (wsModule.WebSocket || wsModule.default?.WebSocket || wsModule.default) as typeof import('ws').WebSocket | undefined + } catch { + logger?.warn('[BrowserShim] ws package not found, WebSocket proxy disabled') + logger?.warn('[BrowserShim] Install with: npm install ws') + } + + // Create HTTP server + server = createServer((req, res) => { + handleRequest(req, res).catch((error) => { + logger?.error('[BrowserShim] Request error:', error) + res.writeHead(500) + res.end('Internal server error') + }) + }) + + // Set up WebSocket server for DevTools proxy + if (WebSocketServerClass) { + const wss = new WebSocketServerClass({ noServer: true }) + + server.on('upgrade', (request: IncomingMessage, socket: any, head: Buffer) => { + const origin = getRequestOrigin(request) + if (origin && !isLoopbackOrigin(origin)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') + socket.destroy() + return + } + + const url = new URL(request.url || '/', `http://${host}:${port}`) + + if (url.pathname !== '/v1/connectDevtools') { + socket.destroy() + return + } + + const sessionId = url.searchParams.get('browser_session') + if (!sessionId) { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n') + socket.destroy() + return + } + + const session = sessions.get(sessionId) + if (!session) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n') + socket.destroy() + return + } + + // Mark session as connected + const connectionId = crypto.randomUUID() + session.connectionId = connectionId + session.connectionStartTime = Date.now() + + // Clear idle timeout since we have an active connection + if (session.idleTimeout) { + clearTimeout(session.idleTimeout) + session.idleTimeout = undefined + } + + wss.handleUpgrade(request, socket, head, (ws: any) => { + if (verbose) { + logger?.debug(`[BrowserShim] WebSocket connected for session ${sessionId}`) + } + + // Connect to Chrome's DevTools WebSocket + const chromeWs = new WebSocketClass(session.wsEndpoint) + let chromeConnected = false + + // Set a connection timeout + const connectTimeout = setTimeout(() => { + if (!chromeConnected) { + logger?.error('[BrowserShim] Chrome connection timeout') + try { + ws.close(1011, 'Chrome connection timeout') + chromeWs.close() + } catch { + // Ignore errors + } + closeSession(sessionId, 5, 'ChromeConnectionTimeout').catch(() => { }) + } + }, 10000) // 10 second timeout + + chromeWs.on('open', () => { + chromeConnected = true + clearTimeout(connectTimeout) + if (verbose) { + logger?.debug('[BrowserShim] Connected to Chrome DevTools') + } + }) + + chromeWs.on('message', (data: Buffer | string) => { + if (ws.readyState === 1) { // OPEN + ws.send(data) + } + }) + + chromeWs.on('close', (code: number, reason: Buffer) => { + if (verbose) { + logger?.debug(`[BrowserShim] Chrome WS closed: ${code}`) + } + // Ensure valid close code (1000-4999) + const validCode = (typeof code === 'number' && code >= 1000 && code <= 4999) ? code : 1000 + try { + ws.close(validCode, reason?.toString?.() || '') + } catch { + // Ignore errors when closing already closed socket + } + + // Chrome connection closed - clean up the session entirely + // This handles crashes, timeouts, and normal closures + closeSession(sessionId, 2, 'ChromeDisconnected').catch((err) => { + logger?.error('[BrowserShim] Error closing session after Chrome disconnect:', err) + }) + }) + + chromeWs.on('error', (error: Error) => { + logger?.error('[BrowserShim] Chrome WS error:', error.message) + try { + ws.close(1011, 'Chrome WebSocket error') + } catch { + // Ignore errors when closing already closed socket + } + + // Chrome error - clean up the session + closeSession(sessionId, 4, 'ChromeError').catch((err) => { + logger?.error('[BrowserShim] Error closing session after Chrome error:', err) + }) + }) + + ws.on('message', (data: Buffer | string) => { + if (chromeWs.readyState === 1) { // OPEN + chromeWs.send(data) + } + }) + + ws.on('close', (code: number, reason: Buffer) => { + if (verbose) { + logger?.debug(`[BrowserShim] Client WS closed for session ${sessionId}`) + } + // Ensure valid close code (1000-4999) + const validCode = (typeof code === 'number' && code >= 1000 && code <= 4999) ? code : 1000 + try { + chromeWs.close(validCode, reason?.toString?.() || '') + } catch { + // Ignore errors when closing already closed socket + } + + // Clear connection from session and close browser immediately + // This prevents zombie browsers from accumulating + const s = sessions.get(sessionId) + if (s && s.connectionId === connectionId) { + s.connectionId = undefined + s.connectionStartTime = undefined + + // Close the browser session immediately when client disconnects + // Don't wait for idle timeout - clean up now + closeSession(sessionId, 1, 'ClientDisconnected').catch((err) => { + logger?.error('[BrowserShim] Error closing session after disconnect:', err) + }) + } + }) + + ws.on('error', (error: Error) => { + logger?.error('[BrowserShim] Client WS error:', error.message) + try { + chromeWs.close() + } catch { + // Ignore errors when closing already closed socket + } + }) + }) + }) + } + + // Start listening + await new Promise((resolve, reject) => { + server!.on('error', reject) + server!.listen(port, host, () => { + resolve() + }) + }) + + logger?.success(`Browser shim server ready on http://${host}:${port}`) + } + + /** + * Stop the server and close all browsers + */ + async function stop(): Promise { + // Close all browser sessions + for (const sessionId of Array.from(sessions.keys())) { + await closeSession(sessionId, 3, 'ServerShutdown') + } + + // Stop server + if (server) { + await new Promise((resolve) => { + server!.close(() => resolve()) + }) + server = null + } + + logger?.info('Browser shim server stopped') + } + + /** + * Get the server URL + */ + function getUrl(): string { + return `http://${host}:${port}` + } + + return { + start, + stop, + getUrl + } +} diff --git a/packages/devflare/src/browser.ts b/packages/devflare/src/browser.ts new file mode 100644 index 0000000..3441c16 --- /dev/null +++ b/packages/devflare/src/browser.ts @@ -0,0 +1,138 @@ +// ============================================================================= +// Devflare โ€” Worker-safe Main Package Entry +// ============================================================================= +// Used by browser/worker-target bundlers so runtime imports like +// `import { env } from 'devflare'` do not pull in CLI, Miniflare, or test-only +// Node.js dependencies. +// ============================================================================= + +// Safe config utilities +export { defineConfig } from './config/define' +export { ref } from './config/ref' + +// Safe runtime-facing exports +export { workerName } from './workerName' +export { env, vars } from './env' + +// Bridge utilities that are safe in worker/browser bundles +export { + setBindingHints, + createEnvProxy, + initEnv +} from './bridge/proxy' +export type { EnvProxyOptions, BindingHints } from './bridge/proxy' +export { BridgeClient, getClient } from './bridge/client' +export type { BridgeClientOptions } from './bridge/client' + +// Decorators +export { + durableObject, + getDurableObjectOptions, + type DurableObjectOptions +} from './decorators' + +type CliModule = typeof import('./cli') +type ConfigModule = typeof import('./config') +type TransformModule = typeof import('./transform') +type MiniflareModule = typeof import('./bridge/miniflare') +type BridgeServerModule = typeof import('./bridge/server') +type TestModule = typeof import('./test') + +type ConfigNotFoundErrorArgs = ConstructorParameters +type ConfigValidationErrorArgs = ConstructorParameters +type ConfigResourceResolutionErrorArgs = ConstructorParameters + +function createUnsupportedApiError(name: string): Error { + return new Error( + `${name} is not available in browser environments; import from devflare/test or devflare/runtime in a worker/Node context instead.` + ) +} + +function unsupportedFunction(name: string): TFunction { + return ((..._args: readonly unknown[]) => { + throw createUnsupportedApiError(name) + }) as unknown as TFunction +} + +// Proxy that refuses ALL forms of introspection. Earlier revisions returned an +// empty object shape (has()=false, ownKeys()=[]) which lied about the absence +// of features โ€” feature-detection code could conclude the export was simply an +// empty module rather than unavailable. Every trap now throws explicitly. +function createUnsupportedObject(name: string): T { + const fail = (): never => { + throw createUnsupportedApiError(name) + } + return new Proxy({} as T, { + get: fail, + has: fail, + ownKeys: fail, + getOwnPropertyDescriptor: fail, + set: fail, + defineProperty: fail, + deleteProperty: fail, + getPrototypeOf: fail + }) +} + +export const loadConfig = unsupportedFunction('loadConfig') +export const loadResolvedConfig = unsupportedFunction('loadResolvedConfig') + +export const compileConfig = unsupportedFunction('compileConfig') +export const stringifyConfig = unsupportedFunction('stringifyConfig') +export const configSchema = createUnsupportedObject('configSchema') + +export class ConfigNotFoundError extends Error { + readonly code = 'CONFIG_NOT_FOUND' + + constructor(..._args: ConfigNotFoundErrorArgs) { + super(createUnsupportedApiError('ConfigNotFoundError').message) + this.name = 'ConfigNotFoundError' + } +} + +export class ConfigValidationError extends Error { + readonly code = 'CONFIG_VALIDATION_ERROR' + + constructor(..._args: ConfigValidationErrorArgs) { + super(createUnsupportedApiError('ConfigValidationError').message) + this.name = 'ConfigValidationError' + } +} + +export class ConfigResourceResolutionError extends Error { + readonly code = 'CONFIG_RESOURCE_RESOLUTION_ERROR' + + constructor(..._args: ConfigResourceResolutionErrorArgs) { + super(createUnsupportedApiError('ConfigResourceResolutionError').message) + this.name = 'ConfigResourceResolutionError' + } +} + +export const runCli = unsupportedFunction('runCli') +export const parseArgs = unsupportedFunction('parseArgs') + +export const findDurableObjectClasses = unsupportedFunction('findDurableObjectClasses') +export const findDurableObjectClassesDetailed = unsupportedFunction('findDurableObjectClassesDetailed') +export const generateWrapper = unsupportedFunction('generateWrapper') +export const transformDurableObject = unsupportedFunction('transformDurableObject') +export const transformWorkerEntrypoint = unsupportedFunction('transformWorkerEntrypoint') +export const findExportedFunctions = unsupportedFunction('findExportedFunctions') +export const shouldTransformWorker = unsupportedFunction('shouldTransformWorker') +export const generateRpcInterface = unsupportedFunction('generateRpcInterface') + +export const startMiniflare = unsupportedFunction('startMiniflare') +export const startMiniflareFromConfig = unsupportedFunction('startMiniflareFromConfig') +export const getMiniflare = unsupportedFunction('getMiniflare') +export const stopMiniflare = unsupportedFunction('stopMiniflare') +export const gateway = createUnsupportedObject('gateway') + +export const createTestContext = unsupportedFunction('createTestContext') +export const createMockTestContext = unsupportedFunction('createMockTestContext') +export const createMockKV = unsupportedFunction('createMockKV') +export const createMockD1 = unsupportedFunction('createMockD1') +export const createMockR2 = unsupportedFunction('createMockR2') +export const createMockQueue = unsupportedFunction('createMockQueue') +export const createMockEnv = unsupportedFunction('createMockEnv') +export const withTestContext = unsupportedFunction('withTestContext') + +export { defineConfig as default } from './config/define' diff --git a/packages/devflare/src/bundler/defaults.ts b/packages/devflare/src/bundler/defaults.ts new file mode 100644 index 0000000..0eeddc0 --- /dev/null +++ b/packages/devflare/src/bundler/defaults.ts @@ -0,0 +1,31 @@ +import type { InputOptions } from 'rolldown' + +/** + * Shared baseline options for the workerd-targeted Rolldown config used by + * both `worker-bundler` (main worker entry) and `do-bundler` (per-class + * Durable Object entries). + * + * Each call site spreads this and overrides only the keys whose values are + * legitimately different between the two surfaces: + * - `platform`: workers run as `'browser'`; per-class DO bundles use + * `'neutral'` so user-side imports of node-only helpers don't get the + * browser shim treatment. + * - `defaultTsconfigMode`: workers honor a user `tsconfig.json` when one + * exists (`'if-present'`); DO entries are virtual, so we always inject + * a default tsconfig (`'always'`). + */ +export interface WorkerdBundlerDefaults { + platform: NonNullable + defaultTsconfigMode: 'always' | 'if-present' + sourcemap: boolean + minify: boolean +} + +export function createWorkerdBundlerDefaults(): WorkerdBundlerDefaults { + return { + platform: 'browser', + defaultTsconfigMode: 'if-present', + sourcemap: false, + minify: false + } +} diff --git a/packages/devflare/src/bundler/do-bundler.ts b/packages/devflare/src/bundler/do-bundler.ts new file mode 100644 index 0000000..a7c2d3a --- /dev/null +++ b/packages/devflare/src/bundler/do-bundler.ts @@ -0,0 +1,569 @@ +// ============================================================================= +// DO Bundler โ€” Rolldown-based Durable Object bundling +// ============================================================================= +// Uses Rolldown for fast bundling of DO files with watch mode +// Supports TypeScript out of the box, handles cloudflare: imports +// ============================================================================= + +import { resolve, dirname, basename, relative } from 'pathe' +import type { ConsolaInstance } from 'consola' +import picomatch from 'picomatch' +import type { DevflareRolldownOptions } from '../config/schema' +import { findFiles, DEFAULT_DO_PATTERN } from '../utils/glob' +import { transformDurableObject } from '../transform/durable-object' +import { discoverDurableObjectFiles } from '../worker-entry/durable-object-discovery' +import { + ensureDebugShim, + resolveWorkerCompatibleRolldownConfig, + writeWorkerCompatibleBundle +} from './rolldown-shared' +import { createWorkerdBundlerDefaults } from './defaults' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface DOBundlerOptions { + /** Project root directory */ + cwd: string + /** Glob pattern for DO files (e.g., 'src/do.*.ts') */ + pattern: string + /** Output directory for bundled files */ + outDir: string + /** Additional Rolldown options for Durable Object bundling */ + rolldownOptions?: DevflareRolldownOptions + /** Default source map setting for emitted bundles */ + sourcemap?: boolean + /** Default minification setting for emitted bundles */ + minify?: boolean + /** Logger instance */ + logger?: ConsolaInstance + /** Callback when a DO is rebuilt */ + onRebuild?: (result: DOBundleResult) => void | Promise +} + +export interface DOBundleResult { + /** Map of binding name โ†’ bundled file path */ + bundles: Map + /** Map of binding name โ†’ class name */ + classes: Map + /** Map of source file โ†’ class names found */ + sourceFiles: Map + /** Errors during bundling */ + errors: Error[] +} + +export interface DOBundler { + /** Initial build of all DOs */ + build(): Promise + /** Start watching for changes */ + watch(): Promise + /** Stop watching */ + close(): Promise + /** Get the latest bundle result */ + getResult(): DOBundleResult +} + +// ----------------------------------------------------------------------------- +// DO Discovery +// ----------------------------------------------------------------------------- + +interface DiscoveredDO { + /** Source file path */ + filePath: string + /** Class name */ + className: string + /** Suggested binding name (e.g., CHAT_ROOM from ChatRoom) */ + bindingName: string +} + +/** + * Convert PascalCase class name to SCREAMING_SNAKE_CASE binding name + */ +function classToBindingName(className: string): string { + return className + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toUpperCase() +} + +/** + * Discover DO classes from a glob pattern. + * Respects .gitignore automatically. + */ +async function discoverDOs(cwd: string, pattern: string): Promise { + const discovered: DiscoveredDO[] = [] + const files = await discoverDurableObjectFiles(cwd, pattern) + + for (const [filePath, classNames] of files) { + for (const className of classNames) { + discovered.push({ + filePath, + className, + bindingName: classToBindingName(className) + }) + } + } + + return discovered +} + +// ----------------------------------------------------------------------------- +// DO Bundling with Rolldown +// ----------------------------------------------------------------------------- + +/** + * Strip @durableObject decorator and its import from source code + * For dev mode, we just need to remove the decorator syntax - the DO class works as-is + */ +function stripDecoratorSyntax(code: string): string { + let result = code + + // 1. Remove @durableObject(...) decorator followed by export class + // Pattern: @durableObject({ ... }) or @durableObject() followed by newlines/whitespace and export class + result = result.replace( + /@durableObject\s*\([^)]*\)\s*\n?\s*(?=export\s+class)/g, + '' + ) + + // 2. Remove import of durableObject from devflare/runtime + // Handle various import patterns: + // - import { durableObject } from 'devflare/runtime' + // - import { durableObject, otherThing } from 'devflare/runtime' + // - import { durableObject as do } from 'devflare/runtime' + + // First try to remove just `durableObject` from multi-import + result = result.replace( + /import\s*\{([^}]*)\bdurableObject\b[^}]*\}\s*from\s*['"]devflare\/runtime['"]\s*;?/g, + (match, imports) => { + // Remove durableObject from the imports list + const cleanedImports = imports + .split(',') + .map((s: string) => s.trim()) + .filter((s: string) => !s.startsWith('durableObject')) + .join(', ') + + if (cleanedImports.trim() === '') { + // No other imports, remove the whole statement + return '' + } + // Keep other imports + return `import { ${cleanedImports} } from 'devflare/runtime'` + } + ) + + return result +} + +// NOTE: @cloudflare/puppeteer is now fully supported via our local browser shim! +// The shim provides a Fetcher service binding that emulates Cloudflare's +// Browser Rendering API using puppeteer-core + chrome-headless-shell. + +/** + * Bundle a single DO file using Rolldown + * + * Strategy: + * 1. Read the source file + * 2. Strip @durableObject decorator (not needed at runtime - just a marker) + * 3. Feed the cleaned code to Rolldown through a virtual-entry plugin whose id + * sits in the source directory, so relative imports resolve identically to + * the original Durable Object module โ€” without ever writing a temp file + * next to user source. + * 4. Bundle with Rolldown. + */ +async function bundleDOFile( + sourcePath: string, + className: string, + outDir: string, + cwd: string, + bundleOptions?: Pick +): Promise { + const fs = await import('node:fs/promises') + + // Ensure output directory exists + await fs.mkdir(outDir, { recursive: true }) + + // Read the original source file + const sourceCode = await fs.readFile(sourcePath, 'utf-8') + + // Apply the Durable Object wrapper transform so event-first handlers receive + // AsyncLocalStorage-backed event injection in non-Vite dev/build paths too. + const transformedCode = (await transformDurableObject(sourceCode, sourcePath))?.code + ?? stripDecoratorSyntax(sourceCode) + + // Create entry code that re-exports the class and has a default fetch handler + const entryCode = `${transformedCode} + +// Default export for worker (required by Miniflare) +export default { + async fetch(request) { + return new Response('DO Worker for ${className}', { status: 200 }); + } +}; +` + + // Virtual entry id lives inside the source directory so rolldown resolves + // relative imports (./Foo, ../bar) from the original DO module's location. + const virtualEntryId = resolve(dirname(sourcePath), `.devflare-do-${className}.virtual.ts`) + + // Output directory for this specific class - clean it first to remove old chunks + const classOutDir = resolve(outDir, className) + try { + await fs.rm(classOutDir, { recursive: true, force: true }) + } catch { + // Ignore if doesn't exist + } + await fs.mkdir(classOutDir, { recursive: true }) + + // Create a shim for the 'debug' module that @cloudflare/puppeteer uses. + const debugShimPath = await ensureDebugShim(outDir) + + const virtualEntryPlugin = { + name: 'devflare-do-virtual-entry', + resolveId(id: string) { + if (id === virtualEntryId) { + return virtualEntryId + } + return null + }, + load(id: string) { + if (id === virtualEntryId) { + return entryCode + } + return null + } + } + + const userRolldownOptions = bundleOptions?.rolldownOptions + const userPlugins = userRolldownOptions?.plugins + const mergedPlugins = userPlugins === undefined + ? [virtualEntryPlugin] + : Array.isArray(userPlugins) + ? [virtualEntryPlugin, ...userPlugins] + : [virtualEntryPlugin, userPlugins] + + const outFile = resolve(classOutDir, 'index.js') + const defaults = createWorkerdBundlerDefaults() + const { inputOptions, outputOptions } = resolveWorkerCompatibleRolldownConfig({ + cwd, + inputFile: virtualEntryId, + outFile, + ...defaults, + platform: 'neutral', + alias: { + debug: debugShimPath + }, + rolldownOptions: { + ...userRolldownOptions, + plugins: mergedPlugins + }, + sourcemap: bundleOptions?.sourcemap ?? defaults.sourcemap, + minify: bundleOptions?.minify ?? defaults.minify, + inlineDynamicImports: true, + defaultTsconfigMode: 'always' + }) + + await writeWorkerCompatibleBundle({ + inputOptions, + outputOptions, + outFile + }) + + // Return path to the bundled entry + return resolve(classOutDir, 'index.js') +} + +/** + * Bundle all discovered DOs + */ +async function bundleAllDOs( + discovered: DiscoveredDO[], + outDir: string, + cwd: string, + logger?: ConsolaInstance, + bundleOptions?: Pick +): Promise { + const fs = await import('node:fs/promises') + const bundles = new Map() + const classes = new Map() + const sourceFiles = new Map() + const errors: Error[] = [] + + // Group by source file + for (const do_ of discovered) { + const existing = sourceFiles.get(do_.filePath) || [] + existing.push(do_.className) + sourceFiles.set(do_.filePath, existing) + } + + // Bundle each DO + for (const do_ of discovered) { + try { + logger?.debug(`Bundling ${do_.className} from ${do_.filePath}`) + + const outFile = await bundleDOFile( + do_.filePath, + do_.className, + outDir, + cwd, + bundleOptions + ) + + bundles.set(do_.bindingName, outFile) + classes.set(do_.bindingName, do_.className) + + logger?.debug(` โ†’ ${outFile}`) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + errors.push(err) + logger?.error(`Failed to bundle ${do_.className}:`, err.message) + } + } + + return { bundles, classes, sourceFiles, errors } +} + +// ----------------------------------------------------------------------------- +// Bundler Factory +// ----------------------------------------------------------------------------- + +/** + * Create a DO bundler with watch support + */ +export function createDOBundler(options: DOBundlerOptions): DOBundler { + const { cwd, pattern, outDir, logger, onRebuild, rolldownOptions, sourcemap, minify } = options + + let result: DOBundleResult = { + bundles: new Map(), + classes: new Map(), + sourceFiles: new Map(), + errors: [] + } + + let watcher: Awaited> | null = null + let chokidarWatcher: import('chokidar').FSWatcher | null = null + + /** + * Perform initial build + */ + async function build(): Promise { + const discovered = await discoverDOs(cwd, pattern) + + if (discovered.length === 0) { + logger?.debug('No DOs found matching pattern:', pattern) + return result + } + + logger?.info(`Found ${discovered.length} Durable Object(s)`) + for (const do_ of discovered) { + logger?.info(` โ€ข ${do_.className} โ†’ ${do_.bindingName}`) + } + + result = await bundleAllDOs(discovered, outDir, cwd, logger, { + rolldownOptions, + sourcemap, + minify + }) + + if (result.errors.length === 0) { + logger?.success(`Bundled ${result.bundles.size} DO(s) to ${outDir}`) + } + + return result + } + + /** + * Watch for changes and rebuild + * + * Strategy: Watch parent directories of DO files with a filter for matching files. + * This allows detection of new DO files created during dev. + * Uses compiled picomatch for fast pattern matching instead of re-globbing on every event. + */ + async function watch(): Promise { + const chokidar = await import('chokidar') + + // Get all source files from the pattern using gitignore-aware glob + const files = await findFiles(pattern, { cwd }) + + // Derive directories to watch from pattern OR existing files + // This ensures we can detect new DO files even if none exist at startup + let dirsToWatch: string[] + + if (files.length > 0) { + // Watch parent directories of existing files + dirsToWatch = [...new Set(files.map((f) => dirname(f)))] + } else { + // No files yet - derive watch directory from pattern + // e.g., "src/do.*.ts" โ†’ watch "src/" + const patternDir = dirname(pattern) + const absolutePatternDir = resolve(cwd, patternDir === '.' ? '' : patternDir) || cwd + dirsToWatch = [absolutePatternDir] + logger?.debug(`No DO files yet, watching pattern directory: ${absolutePatternDir}`) + } + + logger?.info(`Watching ${files.length} DO file(s) in ${dirsToWatch.length} director(ies)...`) + + // Use chokidar for file watching + // Watch directories but filter events for matching files + const isWindows = process.platform === 'win32' + chokidarWatcher = chokidar.watch(dirsToWatch, { + ignoreInitial: true, + // Use polling on Windows for reliability, native fs.watch elsewhere + usePolling: isWindows, + interval: isWindows ? 300 : undefined, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50 + }, + // Depth 0 = only files in the watched directories, not subdirectories + depth: 0 + }) + + // Compile glob pattern once for fast matching + // Normalize paths to forward slashes for cross-platform comparison + const normalizePath = (p: string): string => { + let normalized = p.replace(/\\/g, '/') + // Normalize drive letter casing on Windows (C: vs c:) + if (isWindows && /^[a-zA-Z]:/.test(normalized)) { + normalized = normalized[0].toLowerCase() + normalized.slice(1) + } + return normalized + } + + // Create compiled matcher for the glob pattern + const isMatch = picomatch(pattern, { + dot: true, + // Match from the start of the path + matchBase: false + }) + + // Match file against pattern using relative path + const matchesPattern = (filePath: string): boolean => { + const normalizedPath = normalizePath(filePath) + const relativePath = relative(normalizePath(cwd), normalizedPath) + return isMatch(relativePath) + } + + // Rebuild queue with single-flight guard + // Ensures only one rebuild runs at a time, with at most one pending rebuild + let isRebuilding = false + let pendingRebuild: string | null = null + let rebuildTimeout: ReturnType | null = null + + const scheduleRebuild = (changedPath: string) => { + // Clear any pending debounce timeout + if (rebuildTimeout) { + clearTimeout(rebuildTimeout) + } + + // Debounce: wait 150ms before starting rebuild + rebuildTimeout = setTimeout(() => { + triggerRebuild(changedPath) + }, 150) + } + + const triggerRebuild = async (changedPath: string) => { + // If already rebuilding, queue this one + if (isRebuilding) { + pendingRebuild = changedPath + logger?.debug(`Rebuild already in progress, queuing: ${changedPath}`) + return + } + + isRebuilding = true + + try { + logger?.info(`DO file changed: ${changedPath}`) + logger?.info('Rebuilding DOs...') + const startTime = Date.now() + result = await build() + const elapsed = Date.now() - startTime + logger?.success(`DO rebuild complete (${elapsed}ms)`) + await onRebuild?.(result) + } catch (error) { + logger?.error('DO rebuild failed:', error) + } finally { + isRebuilding = false + + // If another rebuild was queued, run it now + if (pendingRebuild) { + const nextPath = pendingRebuild + pendingRebuild = null + triggerRebuild(nextPath) + } + } + } + + chokidarWatcher.on('change', (filePath) => { + if (matchesPattern(filePath)) { + logger?.debug(`File changed: ${filePath}`) + scheduleRebuild(filePath) + } + }) + + chokidarWatcher.on('add', (filePath) => { + if (matchesPattern(filePath)) { + logger?.debug(`File added: ${filePath}`) + scheduleRebuild(filePath) + } + }) + + chokidarWatcher.on('unlink', (filePath) => { + // Use same pattern matcher for unlink events + // Even though the file is deleted, we can still check if the path matches the pattern + if (matchesPattern(filePath)) { + logger?.debug(`File removed: ${filePath}`) + scheduleRebuild(filePath) + } + }) + + chokidarWatcher.on('ready', () => { + logger?.info('DO file watcher ready') + }) + + chokidarWatcher.on('error', (error) => { + logger?.error('DO file watcher error:', error) + }) + } + + /** + * Stop watching + */ + async function close(): Promise { + if (watcher) { + await watcher.close() + watcher = null + } + if (chokidarWatcher) { + await chokidarWatcher.close() + chokidarWatcher = null + } + } + + /** + * Get the latest result + */ + function getResult(): DOBundleResult { + return result + } + + return { + build, + watch, + close, + getResult + } +} + +// ----------------------------------------------------------------------------- +// Convenience Function +// ----------------------------------------------------------------------------- + +/** + * Bundle DOs without watching (one-shot build) + */ +export async function bundleDOs(options: Omit): Promise { + const bundler = createDOBundler(options) + const result = await bundler.build() + return result +} diff --git a/packages/devflare/src/bundler/index.ts b/packages/devflare/src/bundler/index.ts new file mode 100644 index 0000000..2d8d99a --- /dev/null +++ b/packages/devflare/src/bundler/index.ts @@ -0,0 +1,27 @@ +// ============================================================================= +// Bundler Module โ€” Rolldown-based DO bundling with watch mode +// ============================================================================= +// Provides bundling for Durable Object files with chokidar-based file watching. +// On each change the bundler performs a FULL rebuild of all discovered DOs +// (debounced ~150ms with single-flight + one queued rebuild). This is not +// HMR โ€” the DO worker is re-bundled and re-registered end-to-end. Incremental +// rebuilds are deferred as a larger architectural change. +// ============================================================================= + +export { + type DOBundlerOptions, + type DOBundleResult, + type DOBundler, + createDOBundler, + bundleDOs +} from './do-bundler' +export { + type WorkerBundlerOptions, + bundleWorkerEntry +} from './worker-bundler' +export { + type AliasEntry, + type AliasInput, + mergeAliases, + normalizeAliasEntries +} from './rolldown-shared' diff --git a/packages/devflare/src/bundler/rolldown-shared.ts b/packages/devflare/src/bundler/rolldown-shared.ts new file mode 100644 index 0000000..4f64fdf --- /dev/null +++ b/packages/devflare/src/bundler/rolldown-shared.ts @@ -0,0 +1,342 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'pathe' +import type { + ExternalOption, + InputOptions, + OutputOptions, + RolldownPluginOption +} from 'rolldown' +import type { DevflareRolldownOptions } from '../config/schema' +import { + assertWorkerBundleHasNoDynamicImports, + createWorkerDynamicImportPlugin +} from './worker-compat' + +type ExternalPattern = string | RegExp + +type SanitizedRolldownOptions = DevflareRolldownOptions & + Partial> & { + target?: unknown + } + +type SanitizedRolldownOutputOptions = NonNullable & + Partial> + +const DEFAULT_EXTERNAL_MODULES: ExternalPattern[] = [ + /^cloudflare:/, + /^node:/, + 'buffer', 'crypto', 'events', 'http', 'https', 'net', 'os', 'path', + 'stream', 'tls', 'url', 'util', 'zlib', 'fs', 'child_process', + 'async_hooks', 'querystring', 'string_decoder', 'assert', 'dns' +] + +function toArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +function matchesExternalPattern(pattern: ExternalPattern, id: string): boolean { + if (pattern instanceof RegExp) { + pattern.lastIndex = 0 + return pattern.test(id) + } + + return pattern === id +} + +function matchesExternalOption( + option: ExternalOption | undefined, + id: string, + parentId: string | undefined, + isResolved: boolean +): boolean { + if (!option) { + return false + } + + if (typeof option === 'function') { + return option(id, parentId, isResolved) ?? false + } + + return toArray(option).some((pattern) => matchesExternalPattern(pattern, id)) +} + +function mergeExternalOptions( + base: ExternalOption | undefined, + user: ExternalOption | undefined +): ExternalOption | undefined { + if (!base) { + return user + } + + if (!user) { + return base + } + + if (typeof base !== 'function' && typeof user !== 'function') { + return [...toArray(base), ...toArray(user)] + } + + return (id, parentId, isResolved) => { + return matchesExternalOption(base, id, parentId, isResolved) + || matchesExternalOption(user, id, parentId, isResolved) + || false + } +} + +function mergePluginOptions( + base: RolldownPluginOption | undefined, + user: RolldownPluginOption | undefined +): RolldownPluginOption | undefined { + if (!base) { + return user + } + + if (!user) { + return base + } + + return [base, user] +} + +/** + * Single alias entry. `find` may be a string or RegExp; `replacement` is the + * module id / absolute path the matched specifier resolves to. + */ +export interface AliasEntry { + find: string | RegExp + replacement: string +} + +type RolldownAliasRecord = Record + +/** + * Rolldown's `resolve.alias` option accepts only `Record`. + * We accept the richer `AliasEntry[]` form internally so framework defaults, user + * overrides and future regex-based aliases can flow through a single pipeline. + */ +export type AliasInput = RolldownAliasRecord | AliasEntry[] | undefined + +function aliasKey(find: string | RegExp): string { + return find instanceof RegExp + ? `re:${find.source}:${find.flags}` + : `str:${find}` +} + +export function normalizeAliasEntries(input: AliasInput): AliasEntry[] { + if (!input) { + return [] + } + + if (Array.isArray(input)) { + return input.filter((entry): entry is AliasEntry => { + return Boolean(entry) && typeof entry.replacement === 'string' + }) + } + + return Object.entries(input) + .filter(([, replacement]) => typeof replacement === 'string') + .map(([find, replacement]) => ({ find, replacement: replacement as string })) +} + +/** + * Merge framework-default aliases with user-provided aliases. + * + * Contract: + * - Framework defaults are emitted first, then user entries, so on duplicate + * `find` keys the user entry wins (object-spread / last-wins semantics). + * - Entries are deduplicated by the normalized `find` key (string value or + * `RegExp.source` + flags). + * - Ordering of user entries is preserved so regex specificity remains + * predictable. + */ +export function mergeAliases( + userAliases: AliasEntry[], + frameworkDefaults: AliasEntry[] +): AliasEntry[] { + const userKeys = new Set(userAliases.map((entry) => aliasKey(entry.find))) + + const frameworkFiltered = frameworkDefaults.filter((entry) => { + return !userKeys.has(aliasKey(entry.find)) + }) + + // Dedupe user entries too, keeping the LAST occurrence of each key to match + // object-spread semantics while preserving the relative ordering of that + // last occurrence (important for regex specificity). + const seenUserKeys = new Set() + const dedupedUser: AliasEntry[] = [] + for (let index = userAliases.length - 1;index >= 0;index--) { + const entry = userAliases[index]! + const key = aliasKey(entry.find) + if (seenUserKeys.has(key)) { + continue + } + seenUserKeys.add(key) + dedupedUser.unshift(entry) + } + + return [...frameworkFiltered, ...dedupedUser] +} + +function aliasEntriesToRolldownRecord(entries: AliasEntry[]): RolldownAliasRecord { + const record: RolldownAliasRecord = {} + for (const entry of entries) { + if (entry.find instanceof RegExp) { + // Rolldown's resolve.alias is Record only. RegExp keys + // are carried by `mergeAliases` for future use but cannot be handed to + // rolldown directly; skip them here so we never emit an invalid shape. + continue + } + record[entry.find] = entry.replacement + } + return record +} + +function mergeResolveOptions( + base: InputOptions['resolve'] | undefined, + user: InputOptions['resolve'] | undefined +): InputOptions['resolve'] | undefined { + if (!base && !user) { + return undefined + } + + const frameworkEntries = normalizeAliasEntries(base?.alias as AliasInput) + const userEntries = normalizeAliasEntries(user?.alias as AliasInput) + const mergedEntries = mergeAliases(userEntries, frameworkEntries) + + const mergedAlias = aliasEntriesToRolldownRecord(mergedEntries) + + return { + ...(user ?? {}), + ...(base ?? {}), + ...(mergedEntries.length > 0 ? { alias: mergedAlias } : {}) + } +} + +function resolveTsconfigOption(options: { + cwd: string + userTsconfig: InputOptions['tsconfig'] + defaultMode: 'always' | 'if-present' +}): Pick | {} { + if (options.userTsconfig) { + return { tsconfig: options.userTsconfig } + } + + const defaultTsconfigPath = resolve(options.cwd, 'tsconfig.json') + if (options.defaultMode === 'always' || existsSync(defaultTsconfigPath)) { + return { tsconfig: defaultTsconfigPath } + } + + return {} +} + +export async function ensureDebugShim(outDir: string): Promise { + const fs = await import('node:fs/promises') + const debugShimCode = ` +// Debug module shim for local development +const createDebug = (namespace) => { + const logger = (...args) => { + if (createDebug.enabled) console.debug(\`[\${namespace}]\`, ...args) + } + logger.enabled = false + logger.namespace = namespace + logger.extend = (sub) => createDebug(\`\${namespace}:\${sub}\`) + return logger +} +createDebug.enabled = false +createDebug.formatters = {} +export default createDebug +` + const debugShimPath = resolve(outDir, '_debug_shim.js') + await fs.writeFile(debugShimPath, debugShimCode, 'utf-8') + return debugShimPath +} + +export function resolveWorkerCompatibleRolldownConfig(options: { + cwd: string + inputFile: string + outFile: string + platform: InputOptions['platform'] + alias?: Record + rolldownOptions?: DevflareRolldownOptions + sourcemap?: boolean + minify?: boolean + inlineDynamicImports?: boolean + defaultTsconfigMode: 'always' | 'if-present' +}): { + inputOptions: InputOptions + outputOptions: OutputOptions +} { + const { + output: userOutputOptions, + input: _ignoredInput, + cwd: _ignoredCwd, + platform: _ignoredPlatform, + target: _ignoredTarget, + watch: _ignoredWatch, + external: userExternal, + plugins: userPlugins, + resolve: userResolve, + tsconfig: userTsconfig, + ...userInputOptions + } = (options.rolldownOptions ?? {}) as SanitizedRolldownOptions + + const { + codeSplitting: _ignoredCodeSplitting, + dir: _ignoredDir, + file: _ignoredFile, + format: _ignoredFormat, + inlineDynamicImports: _ignoredInlineDynamicImports, + ...safeUserOutputOptions + } = (userOutputOptions ?? {}) as SanitizedRolldownOutputOptions + + return { + inputOptions: { + ...userInputOptions, + input: options.inputFile, + cwd: options.cwd, + platform: options.platform, + ...resolveTsconfigOption({ + cwd: options.cwd, + userTsconfig, + defaultMode: options.defaultTsconfigMode + }), + external: mergeExternalOptions(DEFAULT_EXTERNAL_MODULES, userExternal), + plugins: mergePluginOptions(createWorkerDynamicImportPlugin(), userPlugins), + resolve: mergeResolveOptions( + options.alias + ? { + alias: options.alias + } + : undefined, + userResolve + ) + }, + outputOptions: { + ...safeUserOutputOptions, + file: options.outFile, + format: 'esm', + sourcemap: safeUserOutputOptions.sourcemap ?? options.sourcemap ?? false, + minify: safeUserOutputOptions.minify ?? options.minify, + codeSplitting: false, + ...(options.inlineDynamicImports !== undefined + ? { inlineDynamicImports: options.inlineDynamicImports } + : {}) + } + } +} + +export async function writeWorkerCompatibleBundle(options: { + inputOptions: InputOptions + outputOptions: OutputOptions + outFile: string +}): Promise { + const { rolldown } = await import('rolldown') + const bundle = await rolldown(options.inputOptions) + + try { + await bundle.write(options.outputOptions) + await assertWorkerBundleHasNoDynamicImports(options.outFile) + } finally { + await bundle.close() + } +} \ No newline at end of file diff --git a/packages/devflare/src/bundler/worker-bundler.ts b/packages/devflare/src/bundler/worker-bundler.ts new file mode 100644 index 0000000..892b217 --- /dev/null +++ b/packages/devflare/src/bundler/worker-bundler.ts @@ -0,0 +1,89 @@ +import { fileURLToPath } from 'node:url' +import type { ConsolaInstance } from 'consola' +import { dirname, resolve } from 'pathe' +import type { DevflareRolldownOptions } from '../config/schema' +import { createWorkerdBundlerDefaults } from './defaults' +import { + ensureDebugShim, + resolveWorkerCompatibleRolldownConfig, + writeWorkerCompatibleBundle +} from './rolldown-shared' + +export interface WorkerBundlerOptions { + cwd: string + inputFile: string + outFile: string + rolldownOptions?: DevflareRolldownOptions + sourcemap?: boolean + minify?: boolean + logger?: ConsolaInstance +} + +async function resolveInternalModuleEntry(relativeCandidates: string[]): Promise { + const fs = await import('node:fs/promises') + const currentFileDir = dirname(fileURLToPath(import.meta.url)) + + for (const candidate of relativeCandidates) { + const absolutePath = resolve(currentFileDir, candidate) + try { + await fs.access(absolutePath) + return absolutePath + } catch { + continue + } + } + + return null +} + +async function resolveInternalAliasMap(outDir: string): Promise> { + const debugShimPath = await ensureDebugShim(outDir) + const runtimeEntry = await resolveInternalModuleEntry([ + '../runtime/index.ts', + '../runtime/index.js' + ]) + const packageEntry = await resolveInternalModuleEntry([ + '../browser.ts', + '../browser.js' + ]) + + return { + debug: debugShimPath, + ...(runtimeEntry ? { 'devflare/runtime': runtimeEntry } : {}), + ...(packageEntry ? { devflare: packageEntry } : {}) + } +} + +export async function bundleWorkerEntry(options: WorkerBundlerOptions): Promise { + const fs = await import('node:fs/promises') + const outDir = dirname(options.outFile) + + await fs.mkdir(outDir, { recursive: true }) + await fs.rm(options.outFile, { force: true }) + await fs.rm(`${options.outFile}.map`, { force: true }) + + const alias = await resolveInternalAliasMap(outDir) + const defaults = createWorkerdBundlerDefaults() + const { inputOptions, outputOptions } = resolveWorkerCompatibleRolldownConfig({ + cwd: options.cwd, + inputFile: options.inputFile, + outFile: options.outFile, + alias, + ...defaults, + platform: 'browser', + rolldownOptions: options.rolldownOptions, + sourcemap: options.sourcemap ?? defaults.sourcemap, + minify: options.minify ?? defaults.minify, + defaultTsconfigMode: 'if-present' + }) + + options.logger?.debug(`Bundling main worker โ†’ ${options.outFile}`) + + await writeWorkerCompatibleBundle({ + inputOptions, + outputOptions, + outFile: options.outFile + }) + + return options.outFile +} \ No newline at end of file diff --git a/packages/devflare/src/bundler/worker-compat.ts b/packages/devflare/src/bundler/worker-compat.ts new file mode 100644 index 0000000..2dca4c5 --- /dev/null +++ b/packages/devflare/src/bundler/worker-compat.ts @@ -0,0 +1,459 @@ +import type { Plugin as RolldownPlugin } from 'rolldown' +import MagicString from 'magic-string' +import ts from 'typescript' + +interface ImportWrapper { + name: string + parameterName: string + bodyStart: number + bodyEnd: number + specifiers: Set + hasUnsupportedCalls: boolean +} + +interface ImportReplacement { + start: number + end: number + text: string +} + +const WORKER_DYNAMIC_IMPORT_ERROR = 'Devflare worker bundles cannot contain unresolved dynamic import() expressions because Miniflare/workerd reject dynamic module specifiers' + +function getScriptKind(id: string): ts.ScriptKind { + if (id.endsWith('.tsx')) { + return ts.ScriptKind.TSX + } + + if (id.endsWith('.ts') || id.endsWith('.mts') || id.endsWith('.cts')) { + return ts.ScriptKind.TS + } + + if (id.endsWith('.jsx')) { + return ts.ScriptKind.JSX + } + + return ts.ScriptKind.JS +} + +function createSourceFile(code: string, id: string): ts.SourceFile { + return ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, getScriptKind(id)) +} + +function hasExportModifier(node: ts.Node): boolean { + if (!ts.canHaveModifiers(node)) { + return false + } + + return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false +} + +function isConstVariableStatement(statement: ts.Statement): statement is ts.VariableStatement { + return ts.isVariableStatement(statement) + && (statement.declarationList.flags & ts.NodeFlags.Const) !== 0 +} + +function quoteString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, `\\'`)}'` +} + +function resolveLiteralImportSpecifier( + expression: ts.Expression, + constStrings: Map +): string | null { + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + return expression.text + } + + if (ts.isParenthesizedExpression(expression)) { + return resolveLiteralImportSpecifier(expression.expression, constStrings) + } + + if (ts.isIdentifier(expression)) { + return constStrings.get(expression.text) ?? null + } + + return null +} + +function collectTopLevelConstStrings(sourceFile: ts.SourceFile): Map { + const constStrings = new Map() + + for (const statement of sourceFile.statements) { + if (!isConstVariableStatement(statement)) { + continue + } + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { + continue + } + + const literal = resolveLiteralImportSpecifier(declaration.initializer, constStrings) + if (literal !== null) { + constStrings.set(declaration.name.text, literal) + } + } + } + + return constStrings +} + +function unwrapDynamicImportExpression(expression: ts.Expression): ts.CallExpression | null { + if (ts.isParenthesizedExpression(expression)) { + return unwrapDynamicImportExpression(expression.expression) + } + + if (ts.isAwaitExpression(expression)) { + return unwrapDynamicImportExpression(expression.expression) + } + + if (!ts.isCallExpression(expression) || expression.expression.kind !== ts.SyntaxKind.ImportKeyword) { + return null + } + + return expression +} + +function getWrapperBodyExpression(body: ts.ConciseBody | undefined): ts.Expression | null { + if (!body) { + return null + } + + if (!ts.isBlock(body)) { + return body + } + + if (body.statements.length !== 1) { + return null + } + + const [statement] = body.statements + if (!ts.isReturnStatement(statement) || !statement.expression) { + return null + } + + return statement.expression +} + +function createImportWrapper( + name: string, + parameterName: string, + body: ts.ConciseBody | undefined, + sourceFile: ts.SourceFile +): ImportWrapper | null { + const bodyExpression = getWrapperBodyExpression(body) + if (!bodyExpression) { + return null + } + + const importExpression = unwrapDynamicImportExpression(bodyExpression) + if (!importExpression || importExpression.arguments.length < 1) { + return null + } + + const [importArgument] = importExpression.arguments + if (!ts.isIdentifier(importArgument) || importArgument.text !== parameterName || !body) { + return null + } + + return { + name, + parameterName, + bodyStart: body.getStart(sourceFile), + bodyEnd: body.getEnd(), + specifiers: new Set(), + hasUnsupportedCalls: false + } +} + +function collectImportWrappers(sourceFile: ts.SourceFile): Map { + const wrappers = new Map() + + for (const statement of sourceFile.statements) { + if ( + ts.isFunctionDeclaration(statement) + && statement.name + && !hasExportModifier(statement) + && statement.parameters.length === 1 + && ts.isIdentifier(statement.parameters[0]?.name) + ) { + const wrapper = createImportWrapper( + statement.name.text, + statement.parameters[0].name.text, + statement.body, + sourceFile + ) + + if (wrapper) { + wrappers.set(wrapper.name, wrapper) + } + } + + if (!isConstVariableStatement(statement) || hasExportModifier(statement)) { + continue + } + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { + continue + } + + if (!ts.isArrowFunction(declaration.initializer) && !ts.isFunctionExpression(declaration.initializer)) { + continue + } + + if ( + declaration.initializer.parameters.length !== 1 + || !ts.isIdentifier(declaration.initializer.parameters[0]?.name) + ) { + continue + } + + const wrapper = createImportWrapper( + declaration.name.text, + declaration.initializer.parameters[0].name.text, + declaration.initializer.body, + sourceFile + ) + + if (wrapper) { + wrappers.set(wrapper.name, wrapper) + } + } + } + + return wrappers +} + +function collectWrapperCallSpecifiers( + sourceFile: ts.SourceFile, + wrappers: Map, + constStrings: Map +): void { + function visit(node: ts.Node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const wrapper = wrappers.get(node.expression.text) + if (wrapper) { + if (node.arguments.length !== 1) { + wrapper.hasUnsupportedCalls = true + } else { + const specifier = resolveLiteralImportSpecifier(node.arguments[0], constStrings) + if (specifier === null) { + wrapper.hasUnsupportedCalls = true + } else { + wrapper.specifiers.add(specifier) + } + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) +} + +function createImportIdentifier(specifierToIdentifier: Map, specifier: string): string { + const existing = specifierToIdentifier.get(specifier) + if (existing) { + return existing + } + + const identifier = `__devflareDynamicImport${specifierToIdentifier.size}` + specifierToIdentifier.set(specifier, identifier) + return identifier +} + +function buildWrapperBody( + wrapper: ImportWrapper, + specifierToIdentifier: Map +): string { + const cases = Array.from(wrapper.specifiers) + .sort((left, right) => left.localeCompare(right)) + .map((specifier) => { + const identifier = createImportIdentifier(specifierToIdentifier, specifier) + return `\t\tcase ${quoteString(specifier)}:\n\t\t\treturn Promise.resolve(${identifier})` + }) + .join('\n') + + return `{ + switch (${wrapper.parameterName}) { +${cases} + default: + return Promise.reject(new Error(\`Unsupported dynamic import in Devflare worker bundle: \${${wrapper.parameterName}}\`)) + } + }` +} + +function findContainingWrapper( + callExpression: ts.CallExpression, + wrappers: ImportWrapper[], + sourceFile: ts.SourceFile +): ImportWrapper | null { + const start = callExpression.getStart(sourceFile) + const end = callExpression.getEnd() + + for (const wrapper of wrappers) { + if (start >= wrapper.bodyStart && end <= wrapper.bodyEnd) { + return wrapper + } + } + + return null +} + +function transformWorkerDynamicImports( + code: string, + id: string +): { + code: string + map: ReturnType +} | null { + const sourceFile = createSourceFile(code, id) + const constStrings = collectTopLevelConstStrings(sourceFile) + const wrappers = collectImportWrappers(sourceFile) + collectWrapperCallSpecifiers(sourceFile, wrappers, constStrings) + + const transformableWrappers = Array.from(wrappers.values()).filter((wrapper) => { + return !wrapper.hasUnsupportedCalls && wrapper.specifiers.size > 0 + }) + + const replacements: ImportReplacement[] = [] + const specifierToIdentifier = new Map() + + function visit(node: ts.Node) { + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const containingWrapper = findContainingWrapper(node, transformableWrappers, sourceFile) + if ( + containingWrapper + && node.arguments.length === 1 + && ts.isIdentifier(node.arguments[0]) + && node.arguments[0].text === containingWrapper.parameterName + ) { + return + } + + const [argument] = node.arguments + if (!argument) { + return + } + + const specifier = resolveLiteralImportSpecifier(argument, constStrings) + if (specifier !== null) { + replacements.push({ + start: node.getStart(sourceFile), + end: node.getEnd(), + text: `Promise.resolve(${createImportIdentifier(specifierToIdentifier, specifier)})` + }) + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + if (replacements.length === 0 && transformableWrappers.length === 0) { + return null + } + + const s = new MagicString(code) + + for (const replacement of replacements.sort((left, right) => right.start - left.start)) { + s.overwrite(replacement.start, replacement.end, replacement.text) + } + + for (const wrapper of transformableWrappers.sort((left, right) => right.bodyStart - left.bodyStart)) { + s.overwrite( + wrapper.bodyStart, + wrapper.bodyEnd, + buildWrapperBody(wrapper, specifierToIdentifier) + ) + } + + if (specifierToIdentifier.size > 0) { + const importBlock = Array.from(specifierToIdentifier.entries()) + .map(([specifier, identifier]) => `import * as ${identifier} from ${quoteString(specifier)}`) + .join('\n') + + if (code.startsWith('#!')) { + const newlineIndex = code.indexOf('\n') + if (newlineIndex === -1) { + // Shebang-only file with no terminating newline: append newline + // after the shebang, then the import block. Avoids double-inserts + // and never rewrites or removes the existing shebang. + s.appendRight(code.length, `\n${importBlock}\n`) + } else { + s.appendLeft(newlineIndex + 1, `${importBlock}\n`) + } + } else { + s.appendLeft(0, `${importBlock}\n`) + } + } + + return { + code: s.toString(), + map: s.generateMap({ + source: id, + file: `${id}.map`, + includeContent: true + }) + } +} + +function findDynamicImportCalls(sourceFile: ts.SourceFile): ts.CallExpression[] { + const calls: ts.CallExpression[] = [] + + function visit(node: ts.Node) { + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + calls.push(node) + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return calls +} + +export async function assertWorkerBundleHasNoDynamicImports(bundlePath: string): Promise { + const fs = await import('node:fs/promises') + const code = await fs.readFile(bundlePath, 'utf-8') + const sourceFile = createSourceFile(code, bundlePath) + const dynamicImports = findDynamicImportCalls(sourceFile) + + if (dynamicImports.length === 0) { + return + } + + const examples = dynamicImports + .slice(0, 3) + .map((callExpression) => { + const start = callExpression.getStart(sourceFile) + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start) + const snippet = code + .slice(start, callExpression.getEnd()) + .replace(/\s+/g, ' ') + .trim() + + return `- ${line + 1}:${character + 1} ${snippet}` + }) + .join('\n') + + throw new Error([ + WORKER_DYNAMIC_IMPORT_ERROR, + `Bundle: ${bundlePath}`, + `Examples:\n${examples}`, + 'Devflare can normalize literal import() calls and simple helper wrappers whose call sites are literal strings, but truly runtime-computed specifiers must be converted to static imports for worker bundles.' + ].join('\n\n')) +} + +export function createWorkerDynamicImportPlugin(): RolldownPlugin { + return { + name: 'devflare-worker-dynamic-imports', + transform(code, id) { + const result = transformWorkerDynamicImports(code, id) + return result ?? null + } + } +} \ No newline at end of file diff --git a/packages/devflare/src/cli/bin.ts b/packages/devflare/src/cli/bin.ts new file mode 100644 index 0000000..1c09fe1 --- /dev/null +++ b/packages/devflare/src/cli/bin.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +// ============================================================================= +// devflare CLI Binary Entry Point +// ============================================================================= + +import { runCli } from './index' + +const args = process.argv.slice(2) + +runCli(args).then((result) => { + process.exit(result.exitCode) +}).catch((error) => { + console.error('Fatal error:', error) + process.exit(1) +}) diff --git a/packages/devflare/src/cli/build-manifest.ts b/packages/devflare/src/cli/build-manifest.ts new file mode 100644 index 0000000..8b2254d --- /dev/null +++ b/packages/devflare/src/cli/build-manifest.ts @@ -0,0 +1,243 @@ +// ============================================================================= +// Build manifest (R2 โ€” fixes C5, C8, C11) +// ============================================================================= +// Persists a deterministic snapshot of the source config + devflare version +// + intended deployment target alongside the build artefact at +// `.devflare/build/manifest.json`. The deploy CLI uses this to: +// +// - C5: detect when `devflare.config.ts` changed between `devflare build` +// and `devflare deploy --build `, so deploys against a stale +// artefact warn instead of silently shipping new bindings against an +// old code bundle. +// - C8: detect when the artefact was built with a `--preview ` +// strategy but is being deployed without one (or vice versa), which +// used to silently ship to production. +// - C11: provide a devflare-version stamp so cross-version artefact +// reuse is at least visible to the deploy preflight. +// +// The manifest is intentionally small and human-readable. It is NOT a +// security control - any local user with write access to `.devflare/build/` +// can edit it - but it is a strong correctness guard against accidental +// stale-artefact deploys. +// ============================================================================= + +import { createHash } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'pathe' +import type { DevflareConfig } from '../config' + +export const BUILD_MANIFEST_VERSION = 1 +export const BUILD_MANIFEST_FILENAME = 'manifest.json' + +export interface BuildManifest { + manifestVersion: typeof BUILD_MANIFEST_VERSION + devflareVersion: string + createdAt: string + sourceConfigHash: string + intendedTarget: { + environment?: string + preview: boolean + previewScope?: string + branchName?: string + } + bindingsSnapshot: { + kv: string[] + d1: string[] + r2: string[] + queues: string[] + rateLimits: string[] + versionMetadata: string[] + workerLoaders: string[] + mtlsCertificates: string[] + dispatchNamespaces: string[] + workflows: string[] + pipelines: string[] + images: string[] + media: string[] + artifacts: string[] + secretsStore: string[] + tailConsumers: string[] + hyperdrive: string[] + vectorize: string[] + services: string[] + } +} + +export interface ManifestDriftReport { + configChanged: boolean + versionChanged: boolean + targetChanged: boolean + previousVersion: string + currentVersion: string + previousTarget: BuildManifest['intendedTarget'] + currentTarget: BuildManifest['intendedTarget'] + bindingsAdded: string[] + bindingsRemoved: string[] +} + +/** + * Compute a stable hash of the source DevflareConfig. Used both at build + * time (to stamp the manifest) and at deploy time (to detect drift). + * + * The hash deliberately ignores transient fields like `accountId` so that + * setting CLOUDFLARE_ACCOUNT_ID between build and deploy doesn't trip drift. + */ +export function hashSourceConfig(config: DevflareConfig): string { + const normalized = JSON.stringify(config, (key, value) => { + // Skip account-id-ish fields that may legitimately differ per env. + if (key === 'accountId') return undefined + // Skip undefined/null sentinels for stable ordering. + if (value === null || value === undefined) return undefined + // `ref()` proxies expose throwing `name`/`config` getters when not + // yet resolved. They show up here via worker/DO bindings whose + // `__ref` back-pointer would otherwise recurse into the proxy. + // Replace any ref proxy with a stable, serializable identifier so + // the hash stays deterministic and never triggers resolution. + if (key === '__ref' && value && typeof value === 'object') { + const nameOverride = (value as { __nameOverride?: string }).__nameOverride + return nameOverride ? `ref:${nameOverride}` : 'ref:' + } + return value + }) + return createHash('sha256').update(normalized).digest('hex') +} + +export function summarizeBindings(config: DevflareConfig): BuildManifest['bindingsSnapshot'] { + const bindings = config.bindings ?? {} + return { + kv: Object.keys(bindings.kv ?? {}).sort(), + d1: Object.keys(bindings.d1 ?? {}).sort(), + r2: Object.keys(bindings.r2 ?? {}).sort(), + queues: Object.keys(bindings.queues ?? {}).sort(), + rateLimits: Object.keys(bindings.rateLimits ?? {}).sort(), + versionMetadata: bindings.versionMetadata ? [bindings.versionMetadata.binding] : [], + workerLoaders: Object.keys(bindings.workerLoaders ?? {}).sort(), + mtlsCertificates: Object.keys(bindings.mtlsCertificates ?? {}).sort(), + dispatchNamespaces: Object.keys(bindings.dispatchNamespaces ?? {}).sort(), + workflows: Object.keys(bindings.workflows ?? {}).sort(), + pipelines: Object.keys(bindings.pipelines ?? {}).sort(), + images: Object.keys(bindings.images ?? {}).sort(), + media: Object.keys(bindings.media ?? {}).sort(), + artifacts: Object.keys(bindings.artifacts ?? {}).sort(), + secretsStore: Object.keys(bindings.secretsStore ?? {}).sort(), + tailConsumers: (config.tailConsumers ?? []) + .map((consumer) => typeof consumer === 'string' ? consumer : consumer.service) + .sort(), + hyperdrive: Object.keys(bindings.hyperdrive ?? {}).sort(), + vectorize: Object.keys(bindings.vectorize ?? {}).sort(), + services: Object.keys(bindings.services ?? {}).sort() + } +} + +export interface CreateBuildManifestOptions { + devflareVersion: string + intendedTarget: BuildManifest['intendedTarget'] +} + +export function createBuildManifest( + config: DevflareConfig, + options: CreateBuildManifestOptions +): BuildManifest { + return { + manifestVersion: BUILD_MANIFEST_VERSION, + devflareVersion: options.devflareVersion, + createdAt: new Date().toISOString(), + sourceConfigHash: hashSourceConfig(config), + intendedTarget: options.intendedTarget, + bindingsSnapshot: summarizeBindings(config) + } +} + +export async function writeBuildManifest( + buildDir: string, + manifest: BuildManifest +): Promise { + const manifestPath = resolve(buildDir, BUILD_MANIFEST_FILENAME) + await writeFile(manifestPath, `${JSON.stringify(manifest, null, '\t')}\n`, 'utf-8') + return manifestPath +} + +export async function readBuildManifest(buildDir: string): Promise { + const manifestPath = resolve(buildDir, BUILD_MANIFEST_FILENAME) + try { + const raw = await readFile(manifestPath, 'utf-8') + const parsed = JSON.parse(raw) as BuildManifest + if (typeof parsed?.manifestVersion !== 'number') return null + return parsed + } catch { + return null + } +} + +function targetsEqual( + a: BuildManifest['intendedTarget'], + b: BuildManifest['intendedTarget'] +): boolean { + return a.environment === b.environment + && a.preview === b.preview + && a.previewScope === b.previewScope + && a.branchName === b.branchName +} + +export function compareManifests( + previous: BuildManifest, + current: BuildManifest +): ManifestDriftReport { + const prevBindings = new Set( + Object.entries(previous.bindingsSnapshot).flatMap(([k, v]) => v.map((n) => `${k}:${n}`)) + ) + const currBindings = new Set( + Object.entries(current.bindingsSnapshot).flatMap(([k, v]) => v.map((n) => `${k}:${n}`)) + ) + const bindingsAdded = [...currBindings].filter((b) => !prevBindings.has(b)) + const bindingsRemoved = [...prevBindings].filter((b) => !currBindings.has(b)) + + return { + configChanged: previous.sourceConfigHash !== current.sourceConfigHash, + versionChanged: previous.devflareVersion !== current.devflareVersion, + targetChanged: !targetsEqual(previous.intendedTarget, current.intendedTarget), + previousVersion: previous.devflareVersion, + currentVersion: current.devflareVersion, + previousTarget: previous.intendedTarget, + currentTarget: current.intendedTarget, + bindingsAdded, + bindingsRemoved + } +} + +export function formatDriftWarning(drift: ManifestDriftReport): string | null { + const lines: string[] = [] + if (drift.versionChanged) { + lines.push(`devflare version differs (built with ${drift.previousVersion}, deploying with ${drift.currentVersion})`) + } + if (drift.targetChanged) { + lines.push( + `deployment target differs (built for ${formatTarget(drift.previousTarget)}, ` + + `deploying as ${formatTarget(drift.currentTarget)})` + ) + } + if (drift.configChanged) { + lines.push('source config changed since build (devflare.config.ts hash differs)') + } + if (drift.bindingsAdded.length > 0) { + lines.push(`bindings added since build: ${drift.bindingsAdded.join(', ')}`) + } + if (drift.bindingsRemoved.length > 0) { + lines.push(`bindings removed since build: ${drift.bindingsRemoved.join(', ')}`) + } + if (lines.length === 0) return null + return [ + 'Build artefact drift detected:', + ...lines.map((line) => ` - ${line}`), + 'Re-run `devflare build` to refresh the artefact, or pass --force to deploy anyway.' + ].join('\n') +} + +function formatTarget(t: BuildManifest['intendedTarget']): string { + const parts: string[] = [] + if (t.environment) parts.push(`env=${t.environment}`) + parts.push(t.preview ? 'preview' : 'production') + if (t.previewScope) parts.push(`scope=${t.previewScope}`) + if (t.branchName) parts.push(`branch=${t.branchName}`) + return parts.join(' ') +} diff --git a/packages/devflare/src/cli/colors.ts b/packages/devflare/src/cli/colors.ts new file mode 100644 index 0000000..6e8924c --- /dev/null +++ b/packages/devflare/src/cli/colors.ts @@ -0,0 +1,19 @@ +// ============================================================================= +// CLI Color Constants โ€” Shared ANSI escape codes for CLI output +// ============================================================================= + +// Text styles +export const BOLD = '\x1b[1m' +export const DIM = '\x1b[2m' +export const RESET = '\x1b[0m' + +// Foreground colors +export const CYAN = '\x1b[36m' +export const CYAN_BOLD = '\x1b[1;36m' +export const WHITE = '\x1b[97m' +export const GREEN = '\x1b[32m' +export const RED = '\x1b[31m' +export const YELLOW = '\x1b[33m' + +// Background colors +export const BG_BLUE = '\x1b[44m' diff --git a/packages/devflare/src/cli/command-utils.ts b/packages/devflare/src/cli/command-utils.ts new file mode 100644 index 0000000..0ad7955 --- /dev/null +++ b/packages/devflare/src/cli/command-utils.ts @@ -0,0 +1,93 @@ +import { getPrimaryAccount } from '../cloudflare/account' +import type { APIClientOptions } from '../cloudflare/api' +import { getEffectiveAccountId, getWorkspaceAccountId } from '../cloudflare/preferences' +import { loadConfig, resolveConfigPath } from '../config/loader' + +export type NamedSelectionSource = 'option' | 'arg' | 'config' | 'none' + +export function asOptionalString(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' && value.trim() + ? value.trim() + : undefined +} + +export function resolveNamedSelection(options: { + explicitValue?: string + fallbackValue?: string + configuredValue?: string +}): { + value?: string + source: NamedSelectionSource +} { + if (options.explicitValue) { + return { + value: options.explicitValue, + source: 'option' + } + } + + if (options.fallbackValue) { + return { + value: options.fallbackValue, + source: 'arg' + } + } + + if (options.configuredValue) { + return { + value: options.configuredValue, + source: 'config' + } + } + + return { + value: undefined, + source: 'none' + } +} + +export async function getConfiguredAccountId(cwd: string): Promise { + const workspaceAccountId = getWorkspaceAccountId() + if (workspaceAccountId) { + return workspaceAccountId + } + + const envAccountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() + if (envAccountId) { + return envAccountId + } + + const configPath = await resolveConfigPath(cwd) + if (!configPath) { + return undefined + } + + try { + const config = await loadConfig({ cwd }) + return config.accountId + } catch { + return undefined + } +} + +export async function resolveCloudflareAccountId(options: { + explicitAccountId?: string + configuredAccountId?: string + apiOptions?: APIClientOptions +}): Promise { + if (options.explicitAccountId) { + return options.explicitAccountId + } + + if (options.configuredAccountId) { + return options.configuredAccountId + } + + const primaryAccount = await getPrimaryAccount(options.apiOptions) + if (!primaryAccount) { + return undefined + } + + const effective = await getEffectiveAccountId(primaryAccount.id) + return effective.accountId +} \ No newline at end of file diff --git a/packages/devflare/src/cli/commands/account.ts b/packages/devflare/src/cli/commands/account.ts new file mode 100644 index 0000000..fd95931 --- /dev/null +++ b/packages/devflare/src/cli/commands/account.ts @@ -0,0 +1,727 @@ +// ============================================================================= +// CLI Account Command +// ============================================================================= +// `devflare account` โ€” View account info, usage, and limits +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { CYAN, CYAN_BOLD, DIM, RESET } from '../colors' +import { + account, + CloudflareAPIError, + AuthenticationError, + type APIClientOptions +} from '../../cloudflare' +import { getConfiguredAccountId } from '../command-utils' +import { + getGlobalDefaultAccountId, + setGlobalDefaultAccountId, + getWorkspaceAccountId, + setWorkspaceAccountId, +} from '../../cloudflare/preferences' +import { + type CliTheme, + bold, + createCliTheme, + dim, + formatCommand, + formatLabelValue, + green, + logLine, + logTable, + whiteDim, + yellow +} from '../ui' + +// ----------------------------------------------------------------------------- +// Subcommands +// ----------------------------------------------------------------------------- + +type AccountSubcommand = + | 'info' + | 'workers' + | 'kv' + | 'd1' + | 'r2' + | 'vectorize' + | 'limits' + | 'usage' + | 'global' + | 'workspace' + +const ACCOUNT_SUBCOMMANDS: AccountSubcommand[] = [ + 'info', + 'workers', + 'kv', + 'd1', + 'r2', + 'vectorize', + 'limits', + 'usage', + 'global', + 'workspace' +] + +function isAccountSubcommand(value: string): value is AccountSubcommand { + return ACCOUNT_SUBCOMMANDS.includes(value as AccountSubcommand) +} + +// CLI commands use a 10-second timeout to avoid long hangs +const CLI_API_OPTIONS: APIClientOptions = { timeout: 10000 } + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +function formatDate(date: Date | undefined): string { + if (!date) return 'N/A' + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) +} + +function formatPercent(value: number | undefined): string { + if (value === undefined) return 'N/A' + return `${value.toFixed(1)}%` +} + +function formatUsageBar(used: number, limit: number | undefined): string { + if (!limit) return 'โ”'.repeat(20) + + const percent = Math.min((used / limit) * 100, 100) + const filled = Math.round((percent / 100) * 20) + const empty = 20 - filled + + const color = percent > 90 ? '๐Ÿ”ด' : percent > 70 ? '๐ŸŸ ' : '๐ŸŸข' + return `${color} ${'โ–ˆ'.repeat(filled)}${'โ–‘'.repeat(empty)} ${used}/${limit}` +} + +function logSection( + logger: ConsolaInstance, + title: string, + theme: CliTheme, + count?: number, + accent: 'cyan' | 'yellow' | 'green' = 'cyan' +): void { + logLine(logger) + const heading = accent === 'yellow' + ? yellow(title, theme) + : accent === 'green' + ? green(title, theme) + : bold(title, theme) + logLine(logger, `${heading}${count === undefined ? '' : ` ${dim(`(${count})`, theme)}`}`) +} + +function logEmptyState(logger: ConsolaInstance, message: string, theme: CliTheme): CliResult { + logLine(logger) + logLine(logger, dim(message, theme)) + logLine(logger) + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Main Command +// ----------------------------------------------------------------------------- + +export async function runAccountCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const theme = createCliTheme(parsed.options) + // Check authentication first + const isAuth = await account.isAuthenticated() + if (!isAuth) { + logger.error('Not authenticated with Cloudflare') + logLine(logger, dim('Run: devflare login', theme)) + return { exitCode: 1 } + } + + // Get subcommand + const subcommand = parsed.args[0] as AccountSubcommand | undefined + const rawSubcommand = parsed.args[0] + + if (rawSubcommand && !isAccountSubcommand(rawSubcommand)) { + logger.error(`Unknown account subcommand: ${rawSubcommand}`) + logLine(logger, dim(`Available account subcommands: ${ACCOUNT_SUBCOMMANDS.join(', ')}`, theme)) + return { exitCode: 1 } + } + + if (subcommand === 'global') { + return await selectGlobalAccount(logger, theme) + } + + if (subcommand === 'workspace') { + return await selectWorkspaceAccount(logger, theme) + } + + try { + // Get account ID from args or use primary account + let accountId = parsed.options.account as string | undefined + + if (!accountId) { + accountId = await getConfiguredAccountId(options.cwd ?? process.cwd()) + } + + if (!accountId) { + const primary = await account.getPrimaryAccount() + if (!primary) { + logger.error('No Cloudflare accounts found') + return { exitCode: 1 } + } + accountId = primary.id + } + + switch (subcommand) { + case 'workers': + return await showWorkers(accountId, logger, theme) + + case 'kv': + return await showKV(accountId, logger, theme) + + case 'd1': + return await showD1(accountId, logger, theme) + + case 'r2': + return await showR2(accountId, logger, theme) + + case 'vectorize': + return await showVectorize(accountId, logger, theme) + + case 'limits': + return await handleLimits(accountId, parsed, logger, theme) + + case 'usage': + return await showUsage(accountId, logger, theme) + + case 'info': + default: + return await showAccountOverview(accountId, logger, theme) + } + } catch (error) { + if (error instanceof AuthenticationError) { + logger.error(error.message) + return { exitCode: 1 } + } + + if (error instanceof CloudflareAPIError) { + logger.error(`API Error: ${error.message}`) + return { exitCode: 1 } + } + + // Handle timeout errors (AbortError or our custom timeout message) + if (error instanceof Error) { + if (error.name === 'AbortError' || error.message.includes('timed out')) { + logger.error('Request timed out. The Cloudflare API is slow or unavailable.') + return { exitCode: 1 } + } + // Log unexpected errors + logger.error(`Error: ${error.message}`) + return { exitCode: 1 } + } + + throw error + } +} + +// ----------------------------------------------------------------------------- +// Account Overview +// ----------------------------------------------------------------------------- + +async function showAccountOverview( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + logSection(logger, 'Accounts', theme) + + let accounts = [] as Awaited> + let limitedAccountView = false + + try { + accounts = await account.getAccounts() + } catch { + const fallbackAccount = await account.getAccountById(accountId) + if (!fallbackAccount) { + logger.error('Could not inspect Cloudflare accounts with the current credentials') + return { exitCode: 1 } + } + + accounts = [fallbackAccount] + limitedAccountView = true + } + + if (accounts.length === 0) { + logger.error('No Cloudflare accounts found') + return { exitCode: 1 } + } + + // Get workspace and global defaults + const workspaceId = getWorkspaceAccountId() + const globalId = await getGlobalDefaultAccountId(accountId) + + if (limitedAccountView) { + logLine(logger, dim('Using the configured account directly because the current credentials cannot enumerate all Cloudflare accounts.', theme)) + } + + // Show all accounts with proper badges + for (let i = 0;i < accounts.length;i++) { + const acc = accounts[i] + const isWorkspace = acc.id === workspaceId + const isGlobal = acc.id === globalId + + // Build badge string + let badge = '' + if (isWorkspace) { + badge = ` ${CYAN_BOLD}(workspace)${RESET}` + } else if (isGlobal) { + // If another account is workspace, dim the global badge + badge = workspaceId + ? ` ${DIM}(global)${RESET}` + : ` ${CYAN}(global)${RESET}` + } + + if (i > 0) { + logLine(logger) + } + logLine(logger, `${dim('account', theme)} ${green(acc.name, theme)}${badge}`) + logLine(logger, formatLabelValue('id', whiteDim(acc.id, theme), theme, 10)) + logLine(logger, formatLabelValue('type', acc.type, theme, 10)) + } + + logSection(logger, 'Commands', theme) + logLine(logger, formatCommand('devflare account global', 'Set global default account', theme)) + logLine(logger, formatCommand('devflare account workspace', 'Set workspace account', theme)) + logLine(logger, formatCommand('devflare account workers', 'List Workers', theme)) + logLine(logger, formatCommand('devflare account kv', 'List KV namespaces', theme)) + logLine(logger, formatCommand('devflare account d1', 'List D1 databases', theme)) + logLine(logger, formatCommand('devflare account r2', 'List R2 buckets', theme)) + logLine(logger, formatCommand('devflare account vectorize', 'List Vectorize indexes', theme)) + logLine(logger, formatCommand('devflare account limits', 'View or set usage limits', theme)) + logLine(logger, formatCommand('devflare account usage', 'View detailed usage', theme)) + logLine(logger, formatCommand('devflare ai', 'View AI models and pricing', theme)) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Account Selection (Global) +// ----------------------------------------------------------------------------- + +async function selectGlobalAccount( + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const accounts = await account.getAccounts() + if (accounts.length === 0) { + logger.error('No Cloudflare accounts found') + return { exitCode: 1 } + } + + if (accounts.length === 1) { + // Only one account - auto-select it + await setGlobalDefaultAccountId(accounts[0].id) + logger.success(`Global default set to: ${accounts[0].name}`) + return { exitCode: 0 } + } + + // Get current global default for initial selection + const currentGlobal = await getGlobalDefaultAccountId(accounts[0].id) + + // Build options for prompt + const options = accounts.map((acc) => { + const isCurrent = acc.id === currentGlobal + return { + label: isCurrent + ? `${acc.name} ${CYAN}(default)${RESET}` + : acc.name, + value: acc.id, + hint: acc.id.substring(0, 8) + '...' + } + }) + + // Show interactive select + const selected = await logger.prompt('Select global default account:', { + type: 'select', + options, + initial: currentGlobal ?? accounts[0].id + }) + + // Handle cancel + if (!selected || typeof selected === 'symbol') { + logLine(logger, dim('Cancelled', theme)) + return { exitCode: 0 } + } + + // Save the selection + await setGlobalDefaultAccountId(selected, accounts[0].id) + + // Find the account name + const selectedAccount = accounts.find((a) => a.id === selected) + logger.success(`Global default set to: ${selectedAccount?.name}`) + logLine(logger, `${dim('saved to', theme)} ~/.devflare/preferences.json + cloud KV`) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Account Selection (Workspace) +// ----------------------------------------------------------------------------- + +async function selectWorkspaceAccount( + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const accounts = await account.getAccounts() + if (accounts.length === 0) { + logger.error('No Cloudflare accounts found') + return { exitCode: 1 } + } + + if (accounts.length === 1) { + // Only one account - auto-select it + const pkgPath = setWorkspaceAccountId(accounts[0].id) + logger.success(`Workspace account set to: ${accounts[0].name}`) + logLine(logger, `${dim('saved to', theme)} ${pkgPath}`) + return { exitCode: 0 } + } + + // Get current workspace default for initial selection + const currentWorkspace = getWorkspaceAccountId() + + // Build options for prompt + const options = accounts.map((acc) => { + const isCurrent = acc.id === currentWorkspace + return { + label: isCurrent + ? `${acc.name} ${CYAN}(workspace)${RESET}` + : acc.name, + value: acc.id, + hint: acc.id.substring(0, 8) + '...' + } + }) + + // Show interactive select + const selected = await logger.prompt('Select workspace account:', { + type: 'select', + options, + initial: currentWorkspace ?? accounts[0].id + }) + + // Handle cancel + if (!selected || typeof selected === 'symbol') { + logLine(logger, dim('Cancelled', theme)) + return { exitCode: 0 } + } + + // Save the selection to package.json + const pkgPath = setWorkspaceAccountId(selected) + + // Find the account name + const selectedAccount = accounts.find((a) => a.id === selected) + logger.success(`Workspace account set to: ${selectedAccount?.name}`) + logLine(logger, `${dim('saved to', theme)} ${pkgPath}`) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Workers +// ----------------------------------------------------------------------------- + +async function showWorkers( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const workers = await account.workers(accountId, CLI_API_OPTIONS) + + if (workers.length === 0) { + return logEmptyState(logger, 'No Workers found', theme) + } + + logSection(logger, 'Workers', theme, workers.length, 'green') + logTable(logger, { + title: 'Worker list', + rows: workers, + columns: [ + { label: 'Name', width: 30, value: (worker) => worker.name }, + { label: 'Modified', width: 20, value: (worker) => whiteDim(formatDate(worker.modifiedOn), theme) } + ], + theme, + titleAccent: 'green' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// KV +// ----------------------------------------------------------------------------- + +async function showKV( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const namespaces = await account.kv(accountId, CLI_API_OPTIONS) + + if (namespaces.length === 0) { + return logEmptyState(logger, 'No KV namespaces found', theme) + } + + logSection(logger, 'KV namespaces', theme, namespaces.length) + logTable(logger, { + title: 'Namespace list', + rows: namespaces, + columns: [ + { label: 'Name', width: 35, value: (ns) => ns.name }, + { label: 'ID', width: 35, value: (ns) => whiteDim(ns.id, theme) } + ], + theme, + titleAccent: 'cyan' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// D1 +// ----------------------------------------------------------------------------- + +async function showD1( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const databases = await account.d1(accountId, CLI_API_OPTIONS) + + if (databases.length === 0) { + return logEmptyState(logger, 'No D1 databases found', theme) + } + + logSection(logger, 'D1 databases', theme, databases.length, 'yellow') + logTable(logger, { + title: 'Database list', + rows: databases, + columns: [ + { label: 'Name', width: 25, value: (db) => db.name }, + { label: 'ID', width: 40, value: (db) => whiteDim(db.id, theme) }, + { label: 'Tables', width: 8, value: (db) => String(db.tableCount ?? 'N/A') } + ], + theme, + titleAccent: 'yellow' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// R2 +// ----------------------------------------------------------------------------- + +async function showR2( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const buckets = await account.r2(accountId, CLI_API_OPTIONS) + + if (buckets.length === 0) { + return logEmptyState(logger, 'No R2 buckets found', theme) + } + + logSection(logger, 'R2 buckets', theme, buckets.length, 'green') + logTable(logger, { + title: 'Bucket list', + rows: buckets, + columns: [ + { label: 'Name', width: 30, value: (bucket) => bucket.name }, + { label: 'Created', width: 20, value: (bucket) => whiteDim(formatDate(bucket.createdOn), theme) }, + { label: 'Location', width: 10, value: (bucket) => bucket.location ?? 'auto' } + ], + theme, + titleAccent: 'green' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Vectorize +// ----------------------------------------------------------------------------- + +async function showVectorize( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const indexes = await account.vectorize(accountId, CLI_API_OPTIONS) + + if (indexes.length === 0) { + return logEmptyState(logger, 'No Vectorize indexes found', theme) + } + + logSection(logger, 'Vectorize indexes', theme, indexes.length, 'yellow') + logTable(logger, { + title: 'Index list', + rows: indexes, + columns: [ + { label: 'Name', width: 25, value: (idx) => idx.name }, + { label: 'Dimensions', width: 12, value: (idx) => String(idx.dimensions) }, + { label: 'Metric', width: 15, value: (idx) => idx.metric } + ], + theme, + titleAccent: 'yellow' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Usage +// ----------------------------------------------------------------------------- + +async function showUsage( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const usages = await account.getAllUsageSummaries(accountId) + const limits = await account.getLimits(accountId) + + logSection(logger, 'Usage', theme, undefined, 'yellow') + logLine(logger, formatLabelValue('limits', limits.enabled ? green('enabled', theme) : dim('disabled', theme), theme, 12)) + + if (usages.length === 0) { + return logEmptyState(logger, 'No usage tracked yet', theme) + } + + logTable(logger, { + title: 'Usage by service', + rows: usages, + columns: [ + { label: 'Service', width: 15, value: (usage) => usage.service }, + { label: 'Today', width: 10, value: (usage) => String(usage.today) }, + { label: 'Limit', width: 10, value: (usage) => usage.limit?.toString() ?? 'โˆž' }, + { label: '%', width: 10, value: (usage) => formatPercent(usage.percentUsed) }, + { label: 'Status', width: 10, value: (usage) => usage.withinLimit ? green('ok', theme) : yellow('limit', theme) } + ], + theme, + titleAccent: 'yellow' + }) + logLine(logger) + + return { exitCode: 0 } +} + +// ----------------------------------------------------------------------------- +// Limits +// ----------------------------------------------------------------------------- + +async function handleLimits( + accountId: string, + parsed: ParsedArgs, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const action = parsed.args[1] as 'set' | 'enable' | 'disable' | undefined + + switch (action) { + case 'set': + return await setLimit(accountId, parsed, logger, theme) + + case 'enable': + await account.setLimitsEnabled(accountId, true) + logger.success('Usage limits enabled') + return { exitCode: 0 } + + case 'disable': + await account.setLimitsEnabled(accountId, false) + logger.success('Usage limits disabled') + return { exitCode: 0 } + + default: + return await showLimits(accountId, logger, theme) + } +} + +async function showLimits( + accountId: string, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const limits = await account.getLimits(accountId) + + logSection(logger, 'Usage limits', theme, undefined, 'yellow') + logLine(logger, formatLabelValue('status', limits.enabled ? green('enabled', theme) : dim('disabled', theme), theme, 16)) + logLine(logger) + logLine(logger, dim('current limits', theme)) + logLine(logger, formatLabelValue('AI Requests/Day', String(limits.aiRequestsPerDay ?? 'Unlimited'), theme, 18)) + logLine(logger, formatLabelValue('AI Tokens/Day', String(limits.aiTokensPerDay ?? 'Unlimited'), theme, 18)) + logLine(logger, formatLabelValue('Vectorize Ops/Day', String(limits.vectorizeOpsPerDay ?? 'Unlimited'), theme, 18)) + logSection(logger, 'Commands', theme) + logLine(logger, formatCommand('devflare account limits set ai-requests 50', 'Set the AI request daily limit', theme)) + logLine(logger, formatCommand('devflare account limits set ai-tokens 5000', 'Set the AI token daily limit', theme)) + logLine(logger, formatCommand('devflare account limits set vectorize-ops 500', 'Set the Vectorize daily limit', theme)) + logLine(logger, formatCommand('devflare account limits enable', 'Enable usage limits', theme)) + logLine(logger, formatCommand('devflare account limits disable', 'Disable usage limits', theme)) + logLine(logger) + + return { exitCode: 0 } +} + +async function setLimit( + accountId: string, + parsed: ParsedArgs, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + const limitName = parsed.args[2] as string | undefined + const limitValue = parsed.args[3] as string | undefined + + if (!limitName || !limitValue) { + logger.error('Usage: devflare account limits set ') + logLine(logger, dim('Limit names: ai-requests, ai-tokens, vectorize-ops', theme)) + return { exitCode: 1 } + } + + const value = parseInt(limitValue, 10) + if (isNaN(value) || value < 0) { + logger.error('Limit value must be a positive number') + return { exitCode: 1 } + } + + switch (limitName) { + case 'ai-requests': + await account.setLimits(accountId, { aiRequestsPerDay: value }) + logger.success(`AI requests limit set to ${value}/day`) + break + + case 'ai-tokens': + await account.setLimits(accountId, { aiTokensPerDay: value }) + logger.success(`AI tokens limit set to ${value}/day`) + break + + case 'vectorize-ops': + await account.setLimits(accountId, { vectorizeOpsPerDay: value }) + logger.success(`Vectorize ops limit set to ${value}/day`) + break + + default: + logger.error(`Unknown limit: ${limitName}`) + logLine(logger, dim('Valid limits: ai-requests, ai-tokens, vectorize-ops', theme)) + return { exitCode: 1 } + } + + return { exitCode: 0 } +} diff --git a/packages/devflare/src/cli/commands/ai.ts b/packages/devflare/src/cli/commands/ai.ts new file mode 100644 index 0000000..59777b8 --- /dev/null +++ b/packages/devflare/src/cli/commands/ai.ts @@ -0,0 +1,153 @@ +// ============================================================================= +// CLI AI Command +// ============================================================================= +// `devflare ai` โ€” View AI models and pricing info +// Pricing scraped from: https://developers.cloudflare.com/workers-ai/platform/pricing/ +// ============================================================================= + +import type { CliResult } from '../index' +import { BOLD, DIM, RESET, BG_BLUE, WHITE } from '../colors' +import { PRICING_DOCS_URL, PRICE_PER_1000_NEURONS_USD, FREE_TIER_NEURONS_PER_DAY } from '../../cloudflare/pricing' + +// ----------------------------------------------------------------------------- +// Pricing Data (from Cloudflare docs) +// ----------------------------------------------------------------------------- + +interface ModelPricing { + model: string + inputPrice: string + outputPrice: string + inputNeurons?: string + outputNeurons?: string +} + +const LLM_PRICING: ModelPricing[] = [ + { model: '@cf/ibm-granite/granite-4.0-h-micro', inputPrice: '$0.017', outputPrice: '$0.112', inputNeurons: '1542', outputNeurons: '10158' }, + { model: '@cf/meta/llama-3.2-1b-instruct', inputPrice: '$0.027', outputPrice: '$0.201', inputNeurons: '2457', outputNeurons: '18252' }, + { model: '@cf/meta/llama-3.2-3b-instruct', inputPrice: '$0.051', outputPrice: '$0.335', inputNeurons: '4625', outputNeurons: '30475' }, + { model: '@cf/qwen/qwen3-30b-a3b-fp8', inputPrice: '$0.051', outputPrice: '$0.335', inputNeurons: '4625', outputNeurons: '30475' }, + { model: '@cf/meta/llama-3.1-8b-instruct-fp8-fast', inputPrice: '$0.045', outputPrice: '$0.384', inputNeurons: '4119', outputNeurons: '34868' }, + { model: '@cf/meta/llama-3.2-11b-vision-instruct', inputPrice: '$0.049', outputPrice: '$0.676', inputNeurons: '4410', outputNeurons: '61493' }, + { model: '@cf/mistral/mistral-7b-instruct-v0.1', inputPrice: '$0.110', outputPrice: '$0.190', inputNeurons: '10000', outputNeurons: '17300' }, + { model: '@cf/meta/llama-3-8b-instruct-awq', inputPrice: '$0.123', outputPrice: '$0.266', inputNeurons: '11161', outputNeurons: '24215' }, + { model: '@cf/meta/llama-3.1-8b-instruct-awq', inputPrice: '$0.123', outputPrice: '$0.266', inputNeurons: '11161', outputNeurons: '24215' }, + { model: '@cf/meta/llama-3.1-8b-instruct-fp8', inputPrice: '$0.152', outputPrice: '$0.287', inputNeurons: '13778', outputNeurons: '26128' }, + { model: '@cf/openai/gpt-oss-20b', inputPrice: '$0.200', outputPrice: '$0.300', inputNeurons: '18182', outputNeurons: '27273' }, + { model: '@cf/meta/llama-4-scout-17b-16e-instruct', inputPrice: '$0.270', outputPrice: '$0.850', inputNeurons: '24545', outputNeurons: '77273' }, + { model: '@cf/meta/llama-3.1-8b-instruct', inputPrice: '$0.282', outputPrice: '$0.827', inputNeurons: '25608', outputNeurons: '75147' }, + { model: '@cf/meta/llama-3-8b-instruct', inputPrice: '$0.282', outputPrice: '$0.827', inputNeurons: '25608', outputNeurons: '75147' }, + { model: '@cf/meta/llama-3.1-70b-instruct-fp8-fast', inputPrice: '$0.293', outputPrice: '$2.253', inputNeurons: '26668', outputNeurons: '204805' }, + { model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', inputPrice: '$0.293', outputPrice: '$2.253', inputNeurons: '26668', outputNeurons: '204805' }, + { model: '@cf/google/gemma-3-12b-it', inputPrice: '$0.345', outputPrice: '$0.556', inputNeurons: '31371', outputNeurons: '50560' }, + { model: '@cf/openai/gpt-oss-120b', inputPrice: '$0.350', outputPrice: '$0.750', inputNeurons: '31818', outputNeurons: '68182' }, + { model: '@cf/mistralai/mistral-small-3.1-24b-instruct', inputPrice: '$0.351', outputPrice: '$0.555', inputNeurons: '31876', outputNeurons: '50488' }, + { model: '@cf/aisingapore/gemma-sea-lion-v4-27b-it', inputPrice: '$0.351', outputPrice: '$0.555', inputNeurons: '31876', outputNeurons: '50488' }, + { model: '@cf/meta/llama-guard-3-8b', inputPrice: '$0.484', outputPrice: '$0.030', inputNeurons: '44003', outputNeurons: '2730' }, + { model: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', inputPrice: '$0.497', outputPrice: '$4.881', inputNeurons: '45170', outputNeurons: '443756' }, + { model: '@cf/meta/llama-2-7b-chat-fp16', inputPrice: '$0.556', outputPrice: '$6.667', inputNeurons: '50505', outputNeurons: '606061' }, + { model: '@cf/qwen/qwq-32b', inputPrice: '$0.660', outputPrice: '$1.000', inputNeurons: '60000', outputNeurons: '90909' }, + { model: '@cf/qwen/qwen2.5-coder-32b-instruct', inputPrice: '$0.660', outputPrice: '$1.000', inputNeurons: '60000', outputNeurons: '90909' } +] + +interface EmbeddingPricing { + model: string + price: string + neurons: string +} + +const EMBEDDING_PRICING: EmbeddingPricing[] = [ + { model: '@cf/baai/bge-m3', price: '$0.012', neurons: '1075' }, + { model: '@cf/qwen/qwen3-embedding-0.6b', price: '$0.012', neurons: '1075' }, + { model: '@cf/pfnet/plamo-embedding-1b', price: '$0.019', neurons: '1689' }, + { model: '@cf/baai/bge-small-en-v1.5', price: '$0.020', neurons: '1841' }, + { model: '@cf/baai/bge-base-en-v1.5', price: '$0.067', neurons: '6058' }, + { model: '@cf/baai/bge-large-en-v1.5', price: '$0.204', neurons: '18582' } +] + +interface ImagePricing { + model: string + tilePrice: string + stepPrice: string +} + +const IMAGE_PRICING: ImagePricing[] = [ + { model: '@cf/black-forest-labs/flux-1-schnell', tilePrice: '$0.0000528', stepPrice: '$0.0001056' }, + { model: '@cf/leonardo/phoenix-1.0', tilePrice: '$0.005830', stepPrice: '$0.000110' }, + { model: '@cf/leonardo/lucid-origin', tilePrice: '$0.006996', stepPrice: '$0.000132' } +] + +interface AudioPricing { + model: string + price: string + unit: string +} + +const AUDIO_PRICING: AudioPricing[] = [ + { model: '@cf/myshell-ai/melotts', price: '$0.0002', unit: 'per audio minute' }, + { model: '@cf/openai/whisper', price: '$0.0005', unit: 'per audio minute' }, + { model: '@cf/openai/whisper-large-v3-turbo', price: '$0.0005', unit: 'per audio minute' }, + { model: '@cf/deepgram/nova-3', price: '$0.0052', unit: 'per audio minute' }, + { model: '@cf/deepgram/flux (WebSocket)', price: '$0.0077', unit: 'per audio minute' }, + { model: '@cf/deepgram/nova-3 (WebSocket)', price: '$0.0092', unit: 'per audio minute' }, + { model: '@cf/deepgram/aura-1', price: '$0.015', unit: 'per 1k characters' }, + { model: '@cf/deepgram/aura-2-en', price: '$0.030', unit: 'per 1k characters' }, + { model: '@cf/deepgram/aura-2-es', price: '$0.030', unit: 'per 1k characters' } +] + +// ----------------------------------------------------------------------------- +// Output Helpers (no โ„น prefix) +// ----------------------------------------------------------------------------- + +function log(message: string = ''): void { + console.log(message) +} + +function header(title: string): void { + console.log(`\n${BG_BLUE}${WHITE}${BOLD} ${title} ${RESET}`) +} + +// ----------------------------------------------------------------------------- +// Main Command +// ----------------------------------------------------------------------------- + +export function runAICommand(): CliResult { + log('๐Ÿค– Workers AI') + log() + log(`${BOLD}Pricing${RESET} $${PRICE_PER_1000_NEURONS_USD.toFixed(3)} per 1,000 neurons`) + log(`${BOLD}Free${RESET} ${FREE_TIER_NEURONS_PER_DAY.toLocaleString('en-US')} neurons/day`) + log(`${DIM}${PRICING_DOCS_URL}${RESET}`) + + // LLM Pricing + header('LLM model pricing') + for (const m of LLM_PRICING) { + log(` โ€ข ${m.model}`) + log(` ${DIM}${m.inputPrice} per M input tokens${RESET}`) + log(` ${DIM}${m.outputPrice} per M output tokens${RESET}`) + } + + // Embedding Pricing + header('Embeddings model pricing') + for (const m of EMBEDDING_PRICING) { + log(` โ€ข ${m.model}`) + log(` ${DIM}${m.price} per M input tokens${RESET}`) + } + + // Image Pricing + header('Image model pricing') + for (const m of IMAGE_PRICING) { + log(` โ€ข ${m.model}`) + log(` ${DIM}${m.tilePrice} per 512x512 tile${RESET}`) + log(` ${DIM}${m.stepPrice} per step${RESET}`) + } + + // Audio Pricing + header('Audio model pricing') + for (const m of AUDIO_PRICING) { + log(` โ€ข ${m.model}`) + log(` ${DIM}${m.price} ${m.unit}${RESET}`) + } + + log() + + return { exitCode: 0 } +} diff --git a/packages/devflare/src/cli/commands/build-artifacts.ts b/packages/devflare/src/cli/commands/build-artifacts.ts new file mode 100644 index 0000000..d0f37cc --- /dev/null +++ b/packages/devflare/src/cli/commands/build-artifacts.ts @@ -0,0 +1,563 @@ +import { type ConsolaInstance } from 'consola' +import { dirname, relative, resolve } from 'pathe' +import type { CliOptions, ParsedArgs } from '../index' +import type { FileSystem } from '../dependencies' +import { + compileBuildConfig, + loadConfig, + resolveConfigEnvVars, + resolveConfigForEnvironment, + type DevflareConfig +} from '../../config' +import { + compileConfig, + isolateViteBuildOutputPaths as isolateCompiledViteBuildOutputPaths, + rebaseWranglerConfigPaths, + writeWranglerConfig, + type WranglerConfig +} from '../../config/compiler' +import { getDependencies } from '../dependencies' +import { ensureGeneratedDirectory, getGeneratedArtifactPaths } from '../generated-artifacts' +import { applyDeploymentStrategy, describeDeploymentStrategy } from '../deploy-strategy' +import { bundleWorkerEntry } from '../../bundler' +import { detectViteProject } from '../../dev-server/vite-utils' +import { + resolveEffectiveViteProject, + writeGeneratedViteConfig, + type EffectiveViteProjectDetection +} from '../../vite' +import { prepareComposedWorkerEntrypoint } from '../../worker-entry/composed-worker' +import { resolvePackageSpecifier } from '../../utils/resolve-package' +import { logLine } from '../ui' +import { + createBuildManifest, + writeBuildManifest, + type BuildManifest +} from '../build-manifest' +import { getPackageVersion } from '../package-metadata' + +type BuildArtifactPaths = ReturnType + +export interface PreparedBuildArtifactsResult { + config: DevflareConfig + wranglerConfig: WranglerConfig + deployConfigPath: string + viteProject: EffectiveViteProjectDetection +} + +interface RetryableCleanupError { + code?: string +} + +interface CleanupFileSystem { + access(path: string): Promise + rename(oldPath: string, newPath: string): Promise + rm( + path: string, + options: { + recursive: boolean + force: boolean + } + ): Promise +} + +function getBuildArtifactPaths(cwd: string): BuildArtifactPaths { + return getGeneratedArtifactPaths(cwd) +} + +function isNestedPath(parentPath: string, candidatePath: string): boolean { + const normalizedParentPath = parentPath.replace(/\\/g, '/') + const normalizedCandidatePath = candidatePath.replace(/\\/g, '/') + + return normalizedCandidatePath.startsWith(`${normalizedParentPath}/`) +} + +export function isolateViteBuildOutputPaths( + cwd: string, + wranglerConfig: WranglerConfig +): WranglerConfig { + return isolateCompiledViteBuildOutputPaths(cwd, wranglerConfig) +} + +export function getViteBuildCleanupTargets(cwd: string, wranglerConfig: WranglerConfig): string[] { + const targets: string[] = [] + const assetsDirectory = wranglerConfig.assets?.directory + const mainEntry = wranglerConfig.main + + if (assetsDirectory) { + targets.push(resolve(cwd, assetsDirectory)) + } + + if (mainEntry) { + const mainEntryPath = resolve(cwd, mainEntry) + const isCoveredByAssetsDirectory = targets.some((targetPath) => { + return mainEntryPath === targetPath || isNestedPath(targetPath, mainEntryPath) + }) + + if (!isCoveredByAssetsDirectory) { + targets.push(mainEntryPath) + } + } + + return targets +} + +function shouldRetryCleanup(error: unknown): error is RetryableCleanupError { + if (!error || typeof error !== 'object') { + return false + } + + const errorCode = (error as RetryableCleanupError).code + return errorCode === 'EBUSY' || errorCode === 'EPERM' || errorCode === 'ENOTEMPTY' +} + +async function getCleanupFileSystem(): Promise { + return await import('node:fs/promises') +} + +async function pathExists(cleanupFs: CleanupFileSystem, targetPath: string): Promise { + try { + await cleanupFs.access(targetPath) + return true + } catch { + return false + } +} + +export function createDeferredCleanupPath(targetPath: string, uniqueSuffix?: string): string { + const suffix = + uniqueSuffix ?? + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + + return `${targetPath}.devflare-stale-${suffix}` +} + +async function tryMoveLockedPathAside( + targetPath: string, + logger: ConsolaInstance, + cleanupFs: CleanupFileSystem +): Promise { + if (!(await pathExists(cleanupFs, targetPath))) { + return true + } + + const deferredCleanupPath = createDeferredCleanupPath(targetPath) + + try { + await cleanupFs.rename(targetPath, deferredCleanupPath) + } catch { + return false + } + + logger.warn( + `Moved locked build output aside to ${deferredCleanupPath} after repeated cleanup failures; continuing build` + ) + + try { + await cleanupFs.rm(deferredCleanupPath, { + recursive: true, + force: true + }) + } catch (error) { + const cleanupErrorCode = + error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : 'an unknown error' + + logger.warn( + `Deferred cleanup for ${deferredCleanupPath} is still blocked by ${cleanupErrorCode}; you can remove it manually later` + ) + } + + return true +} + +export async function removePathWithRetries( + targetPath: string, + logger: ConsolaInstance, + attempts: number = 5, + cleanupFs?: CleanupFileSystem +): Promise { + const fs = cleanupFs ?? await getCleanupFileSystem() + let lastError: unknown + + for (let attempt = 1;attempt <= attempts;attempt++) { + try { + await fs.rm(targetPath, { + recursive: true, + force: true + }) + return + } catch (error) { + lastError = error + + if (!shouldRetryCleanup(error) || attempt === attempts) { + break + } + + logger.warn( + `Retrying cleanup for ${targetPath} after ${error.code} (${attempt}/${attempts})` + ) + await new Promise((resolveRetry) => setTimeout(resolveRetry, attempt * 100)) + } + } + + if ( + shouldRetryCleanup(lastError) && + await tryMoveLockedPathAside(targetPath, logger, fs) + ) { + return + } + + if (shouldRetryCleanup(lastError)) { + const cleanupErrorCode = + lastError instanceof Error && 'code' in lastError && typeof lastError.code === 'string' + ? lastError.code + : 'an unknown error' + + logger.warn( + `Continuing build without pre-clean for ${targetPath} because cleanup is still blocked by ${cleanupErrorCode}` + ) + return + } + + throw lastError +} + +export async function cleanupViteBuildOutputs( + cwd: string, + wranglerConfig: WranglerConfig, + logger: ConsolaInstance +): Promise { + const cleanupTargets = getViteBuildCleanupTargets(cwd, wranglerConfig) + + for (const cleanupTarget of cleanupTargets) { + await removePathWithRetries(cleanupTarget, logger) + } +} + +async function writeDeployRedirect(cwd: string, generatedConfigPath: string): Promise { + const fs = await import('node:fs/promises') + const paths = getBuildArtifactPaths(cwd) + await ensureGeneratedDirectory(paths.deployDir) + + const configPath = relative(paths.deployDir, generatedConfigPath).replace(/\\/g, '/') + await fs.writeFile( + paths.deployRedirectPath, + `${JSON.stringify({ configPath }, null, '\t')}\n`, + 'utf-8' + ) +} + +async function readDeployRedirect(cwd: string): Promise { + const fs = await import('node:fs/promises') + const paths = getBuildArtifactPaths(cwd) + + try { + const rawConfig = await fs.readFile(paths.deployRedirectPath, 'utf-8') + const parsed = JSON.parse(rawConfig) as { configPath?: unknown } + if (typeof parsed.configPath !== 'string' || parsed.configPath.length === 0) { + return null + } + + return resolve(dirname(paths.deployRedirectPath), parsed.configPath) + } catch { + return null + } +} + +async function writeGeneratedDeployWranglerConfig( + cwd: string, + wranglerConfig: WranglerConfig, + options: { + main?: string + } = {} +): Promise { + const paths = getBuildArtifactPaths(cwd) + await ensureGeneratedDirectory(paths.buildDir, true) + + const buildConfig = rebaseWranglerConfigPaths(cwd, paths.buildDir, wranglerConfig) + + if (options.main) { + buildConfig.main = options.main + } + + await writeWranglerConfig(paths.buildDir, buildConfig, 'wrangler.jsonc') + return paths.buildWranglerConfigPath +} + +async function writeGeneratedDevWranglerConfig( + cwd: string, + wranglerConfig: WranglerConfig +): Promise { + const paths = getGeneratedArtifactPaths(cwd) + await ensureGeneratedDirectory(paths.devflareDir, true) + + const devConfig = rebaseWranglerConfigPaths(cwd, paths.devflareDir, wranglerConfig) + + await writeWranglerConfig(paths.devflareDir, devConfig, 'wrangler.jsonc') + return paths.devWranglerConfigPath +} + +async function buildWorkerOnlyDeployArtifact( + cwd: string, + wranglerConfig: WranglerConfig, + config: DevflareConfig, + logger: ConsolaInstance +): Promise { + if (!wranglerConfig.main) { + return await writeGeneratedDeployWranglerConfig(cwd, wranglerConfig) + } + + const paths = getBuildArtifactPaths(cwd) + const bundledMainEntryPath = await bundleWorkerEntry({ + cwd, + inputFile: resolve(cwd, wranglerConfig.main), + outFile: paths.buildWorkerPath, + rolldownOptions: config.rolldown?.options, + sourcemap: config.rolldown?.sourcemap, + minify: config.rolldown?.minify, + logger + }) + + logLine(logger, `Generated deploy artifact: ${relative(cwd, bundledMainEntryPath).replace(/\\/g, '/')}`) + return await writeGeneratedDeployWranglerConfig(cwd, wranglerConfig, { + main: './worker.js' + }) +} + +export async function resolveLocalViteExecutable(cwd: string, fs: FileSystem): Promise { + // Prefer a workspace-local node_modules path over `import.meta.resolve`. + // + // Under Bun on Windows, `import.meta.resolve('vite/bin/vite.js')` can return + // Bun's install-cache realpath (e.g. `C:\Users\โ€ฆ\.bun\install\cache\vite@8.0.9@@@1\bin\vite.js`). + // Executing that realpath via Node breaks Vite 8's resolution of `rolldown` and other + // transitive deps, because Node's resolver no longer sees the workspace's hoisted + // node_modules tree from the cache directory. + // + // Walking up `node_modules/vite/bin/vite.js` from cwd preserves the symlinked path + // inside the workspace, which keeps Node's package resolution intact. + const workspaceLocal = await findWorkspaceLocalBinary(cwd, fs, ['vite', 'bin', 'vite.js']) + if (workspaceLocal) { + return workspaceLocal + } + + const viteExecutablePath = resolvePackageSpecifier('vite/bin/vite.js', cwd) + + try { + await fs.access(viteExecutablePath) + } catch { + throw new Error( + `Could not resolve a local Vite CLI entrypoint from ${cwd}. Install vite in this package before running a Vite-backed Devflare build.` + ) + } + + return viteExecutablePath +} + +/** + * Walk up the directory tree from `startDir` looking for + * `node_modules/`. Returns the first match or null. + * + * This preserves the workspace-local (symlinked) path rather than the + * package manager's underlying cache realpath, which matters for Node + * package resolution semantics under Bun on Windows. + */ +export async function findWorkspaceLocalBinary( + startDir: string, + fs: FileSystem, + segments: readonly string[] +): Promise { + let currentDir = resolve(startDir) + // Bound the walk by the filesystem root. + for (let depth = 0;depth < 64;depth++) { + const candidate = resolve(currentDir, 'node_modules', ...segments) + try { + await fs.access(candidate) + return candidate + } catch { + // not here, walk up + } + const parent = dirname(currentDir) + if (parent === currentDir) { + return null + } + currentDir = parent + } + return null +} + +/** True when the current process is Bun (and `bun` is therefore on PATH). */ +export function isRunningUnderBun(): boolean { + return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' + || typeof (process.versions as { bun?: string }).bun === 'string' +} + +export async function prepareBuildArtifacts( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd || process.cwd() + const configPath = parsed.options.config as string | undefined + const environment = parsed.options.env as string | undefined + + const rawConfig = await loadConfig({ cwd, configFile: configPath }) + const config = await resolveConfigEnvVars( + resolveConfigForEnvironment(rawConfig, environment), + { + cwd, + configPath, + mode: 'build' + } + ) + + logLine(logger, `Building: ${config.name}`) + + const composedMainEntry = await prepareComposedWorkerEntrypoint(cwd, config, environment) + const deps = await getDependencies() + const viteProject = resolveEffectiveViteProject( + await detectViteProject(cwd, deps.fs as unknown as Parameters[1]), + config, + environment + ) + const deploymentStrategy = applyDeploymentStrategy(config, { + environment, + preview: parsed.options.preview === true, + branchName: parsed.options['branch-name'] as string | undefined, + previewBranch: process.env.DEVFLARE_PREVIEW_BRANCH + }) + const deploymentStrategyMessage = describeDeploymentStrategy(deploymentStrategy) + + if (deploymentStrategyMessage) { + logLine(logger, deploymentStrategyMessage) + } + + const devWranglerConfig = viteProject.shouldStartVite + ? isolateViteBuildOutputPaths(cwd, compileBuildConfig(config)) + : compileBuildConfig(config) + const deployWranglerConfig = viteProject.shouldStartVite + ? isolateViteBuildOutputPaths(cwd, compileBuildConfig(deploymentStrategy.config)) + : compileBuildConfig(deploymentStrategy.config) + + if (viteProject.shouldStartVite) { + if (composedMainEntry) { + deployWranglerConfig.main = relative(cwd, composedMainEntry) + logLine(logger, `Generated composed worker entry: ${deployWranglerConfig.main}`) + } + } else if (composedMainEntry) { + const bundledMainEntryPath = await bundleWorkerEntry({ + cwd, + inputFile: composedMainEntry, + outFile: resolve(cwd, '.devflare', 'worker-entrypoints', 'main.js'), + rolldownOptions: config.rolldown?.options, + sourcemap: config.rolldown?.sourcemap, + minify: config.rolldown?.minify, + logger + }) + const bundledMainPath = relative(cwd, bundledMainEntryPath).replace(/\\/g, '/') + devWranglerConfig.main = bundledMainPath + deployWranglerConfig.main = bundledMainPath + logLine(logger, `Generated bundled worker entry: ${bundledMainPath}`) + } + + let deployConfigPath: string + + if (viteProject.shouldStartVite) { + const generatedViteConfigPath = await writeGeneratedViteConfig({ + cwd, + configPath, + environment, + localConfigPath: viteProject.viteConfigPath + }) + const viteExecutablePath = await resolveLocalViteExecutable(cwd, deps.fs) + + await cleanupViteBuildOutputs(cwd, devWranglerConfig, logger) + logLine(logger, 'Running vite build...') + + // When running under Bun, invoke Vite through `bun --bun ` rather + // than letting execa launch Node directly. Two reasons: + // 1. Bun preserves workspace-local package resolution even if the + // executable file path is a hoisted/cache symlink target. + // 2. Vite 8's `rolldown` import resolves correctly under Bun on Windows. + // `--bun` forces Bun's runtime even when the script has a Node shebang. + const useBunRuntime = isRunningUnderBun() + const buildCommand = useBunRuntime ? 'bun' : viteExecutablePath + const buildArgs = useBunRuntime + ? ['--bun', viteExecutablePath, 'build', '--config', generatedViteConfigPath] + : ['build', '--config', generatedViteConfigPath] + + const buildProc = await deps.exec.exec(buildCommand, buildArgs, { + cwd, + stdio: 'inherit', + env: { + ...process.env, + DEVFLARE_BUILD: 'true' + }, + // Don't reject on non-zero exit โ€” we want to surface a richer error below. + reject: false + }) + + if (buildProc.exitCode !== 0) { + throw new Error( + `Vite build failed (exit code ${buildProc.exitCode}).\n` + + `\n` + + `Command: ${buildCommand} ${buildArgs.join(' ')}\n` + + `Working directory: ${cwd}\n` + + `Vite executable: ${viteExecutablePath}\n` + + `Runtime: ${useBunRuntime ? 'bun --bun' : 'node (default execa runtime)'}\n` + + `\n` + + `Vite's own output is printed above. If you only see "UNHANDLED PROMISE REJECTION"\n` + + `with no other detail, common causes are:\n` + + ` - A Vite plugin or transitive dependency (e.g. rolldown) cannot be resolved\n` + + ` from the executable's physical path. This commonly happens when the package\n` + + ` manager resolves the Vite binary to a global cache directory outside the\n` + + ` workspace's node_modules tree. Try reinstalling, or run vite directly to\n` + + ` isolate: \`bunx --bun vite build --config ${relative(cwd, generatedViteConfigPath).replace(/\\/g, '/')}\`.\n` + + ` - A peer dependency or framework adapter is missing. Re-check the package's\n` + + ` devDependencies against the framework's documented requirements.\n` + + ` - The generated vite config references a path that does not yet exist.` + ) + } + + const existingDeployConfigPath = await readDeployRedirect(cwd) + const generatedDeployConfigPath = deployWranglerConfig.main && deployWranglerConfig.main !== devWranglerConfig.main + ? await buildWorkerOnlyDeployArtifact(cwd, deployWranglerConfig, config, logger) + : await writeGeneratedDeployWranglerConfig(cwd, deployWranglerConfig) + + deployConfigPath = existingDeployConfigPath && existingDeployConfigPath !== generatedDeployConfigPath + ? existingDeployConfigPath + : generatedDeployConfigPath + } else { + logLine(logger, 'Skipping Vite build (no effective Vite config found for this package)') + deployConfigPath = await buildWorkerOnlyDeployArtifact(cwd, deployWranglerConfig, config, logger) + } + + const generatedDevConfigPath = await writeGeneratedDevWranglerConfig(cwd, devWranglerConfig) + logger.debug(`Generated dev Wrangler config: ${relative(cwd, generatedDevConfigPath).replace(/\\/g, '/')}`) + + await writeDeployRedirect(cwd, deployConfigPath) + logLine(logger, `Generated deploy Wrangler config: ${relative(cwd, deployConfigPath).replace(/\\/g, '/')}`) + logLine(logger, `Generated deploy redirect: ${relative(cwd, getBuildArtifactPaths(cwd).deployRedirectPath).replace(/\\/g, '/')}`) + + // R2: emit a build manifest alongside the artefact so deploy can detect + // drift (config edits, version skew, target mismatch) before shipping. + const manifest = createBuildManifest(config, { + devflareVersion: await getPackageVersion(), + intendedTarget: { + environment, + preview: parsed.options.preview === true, + previewScope: typeof parsed.options.preview === 'string' ? parsed.options.preview : undefined, + branchName: parsed.options['branch-name'] as string | undefined + } + }) + const manifestPath = await writeBuildManifest(getBuildArtifactPaths(cwd).buildDir, manifest) + logger.debug(`Generated build manifest: ${relative(cwd, manifestPath).replace(/\\/g, '/')}`) + + return { + config, + wranglerConfig: deployWranglerConfig, + deployConfigPath, + viteProject + } +} diff --git a/packages/devflare/src/cli/commands/build.ts b/packages/devflare/src/cli/commands/build.ts new file mode 100644 index 0000000..034146c --- /dev/null +++ b/packages/devflare/src/cli/commands/build.ts @@ -0,0 +1,35 @@ +// ============================================================================= +// Build Command โ€” Build for production +// ============================================================================= + +import { type ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { prepareBuildArtifacts } from './build-artifacts' +import { createCliTheme, cyanBold, dim, logLine } from '../ui' + +export async function runBuildCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const theme = createCliTheme(parsed.options) + logLine(logger) + logLine(logger, `${cyanBold('build', theme)} ${dim('Preparing production artifacts', theme)}`) + + try { + await prepareBuildArtifacts(parsed, logger, options) + logger.success('Generated .devflare/wrangler.jsonc') + logger.success('Generated .devflare/build/wrangler.jsonc') + logger.success('Generated .wrangler/deploy/config.json') + logger.success('Build complete!') + return { exitCode: 0 } + } catch (error) { + if (error instanceof Error) { + logger.error('Build failed:', error.message) + if (parsed.options.debug) { + logger.error(error.stack) + } + } + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/config.ts b/packages/devflare/src/cli/commands/config.ts new file mode 100644 index 0000000..8a182a4 --- /dev/null +++ b/packages/devflare/src/cli/commands/config.ts @@ -0,0 +1,121 @@ +import { type ConsolaInstance } from 'consola' +import { + ConfigResourceResolutionError, + loadConfig, + loadResolvedConfig, + resolveConfigEnvVars, + resolveResources, + type DevflareConfig +} from '../../config' +import { compileBuildConfig, compileConfig } from '../../config/compiler' +import type { ParsedArgs, CliOptions, CliResult } from '../index' + +function isSupportedFormat(value: string): value is 'devflare' | 'wrangler' { + return value === 'devflare' || value === 'wrangler' +} + +type ConfigPhase = 'build' | 'local' | 'deploy' + +function isSupportedPhase(value: string): value is ConfigPhase { + return value === 'build' || value === 'local' || value === 'deploy' +} + +async function loadConfigForPhase(options: { + cwd: string + configPath: string | undefined + environment: string | undefined + phase: ConfigPhase +}): Promise { + if (options.phase === 'deploy') { + const resolvedConfig = await loadResolvedConfig({ + cwd: options.cwd, + configFile: options.configPath, + environment: options.environment + }) + return await resolveConfigEnvVars(resolvedConfig, { + cwd: options.cwd, + configPath: options.configPath, + mode: 'build' + }) + } + + const config = await loadConfig({ + cwd: options.cwd, + configFile: options.configPath + }) + const resourceResolvedConfig = await resolveResources(config, { + phase: options.phase, + environment: options.environment + }) + return await resolveConfigEnvVars(resourceResolvedConfig, { + cwd: options.cwd, + configPath: options.configPath, + mode: options.phase === 'local' ? 'dev' : 'build' + }) +} + +export async function runConfigCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd || process.cwd() + const configPath = parsed.options.config as string | undefined + const environment = parsed.options.env as string | undefined + const subcommand = parsed.args[0] ?? 'print' + const formatOption = parsed.options.format as string | undefined + const format = formatOption ?? 'devflare' + const phaseOption = parsed.options.local === true + ? 'local' + : (parsed.options.phase as string | undefined) ?? 'deploy' + + if (subcommand !== 'print') { + logger.error(`Unknown config subcommand: ${subcommand}`) + logger.info('Supported subcommands: print') + return { exitCode: 1 } + } + + if (!isSupportedFormat(format)) { + logger.error(`Unsupported config format: ${format}`) + logger.info('Supported formats: devflare, wrangler') + return { exitCode: 1 } + } + + if (!isSupportedPhase(phaseOption)) { + logger.error(`Unsupported config phase: ${phaseOption}`) + logger.info('Supported phases: build, local, deploy') + return { exitCode: 1 } + } + + try { + const resolvedConfig = await loadConfigForPhase({ + cwd, + configPath, + environment, + phase: phaseOption + }) + const output = format === 'wrangler' + ? phaseOption === 'build' + ? compileBuildConfig(resolvedConfig, undefined, { alreadyResolved: true }) + : compileConfig(resolvedConfig as Parameters[0]) + : resolvedConfig + const text = JSON.stringify(output, null, '\t') + + if (!options.silent) { + process.stdout.write(`${text}\n`) + } + + return { + exitCode: 0, + output: text + } + } catch (error) { + if (error instanceof Error) { + logger.error('Config command failed:', error.message) + if (error instanceof ConfigResourceResolutionError) { + logger.info('For offline inspection, run `devflare config --phase local` or `devflare config --phase build --format wrangler`.') + } + } + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/deploy.ts b/packages/devflare/src/cli/commands/deploy.ts new file mode 100644 index 0000000..bd15cfd --- /dev/null +++ b/packages/devflare/src/cli/commands/deploy.ts @@ -0,0 +1,601 @@ +// ============================================================================= +// Deploy Command: deploy to Cloudflare +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import { join } from 'pathe' +import { getWorkersSubdomain } from '../../cloudflare/account' +import { reconcilePreviewRegistry } from '../../cloudflare/preview-registry' +import { + compileBuildConfig, + compileConfig, + loadConfig, + prepareConfigResourcesForDeploy, + resolveConfigEnvVars, + resolveConfigForEnvironment +} from '../../config' +import { stringifyConfig } from '../../config/compiler' +import { getDependencies } from '../dependencies' +import { applyDeploymentStrategy, describeDeploymentStrategy } from '../deploy-strategy' +import { + applyResolvedDeployTarget, + resolveDeployTarget, + withTemporaryEnvironment +} from '../deploy-target' +import type { CliOptions, CliResult, ParsedArgs } from '../index' +import { + formatWorkersDevUrl, + mergeParsedWranglerDeployOutputs, + parseWranglerDeployOutput, + parseWranglerStructuredOutput +} from '../preview' +import { createCliTheme, dim, green, logLine, yellow, yellowBold } from '../ui' +import { prepareBuildArtifacts } from './build-artifacts' +import { inferRecordSource, writeDeployResultMetadata } from './deploy/metadata' +import { + prepareDeployConfig, + resolveBuildArtifactConfigPath, + summarizeDeployResourceNames +} from './deploy/prepare' +import { resolveLocalWranglerExecutable } from './deploy/runtime' +import { + normalizeCloudflareAccountId, + resolveDeployAccountId, + resolveVersionIdFromCurrentProductionDeployment, + resolveVersionIdFromLatestProductionDeployment, + resolveVersionIdFromLatestWorkerVersion, + shouldRequireFreshProductionDeployment, + shouldVerifyDeployControlPlane, + verifyDeployControlPlane +} from './deploy/verification' + +export async function runDeployCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + let deployTarget = { + mode: 'implicit', + envOverrides: {} + } as ReturnType + let resolvedParsed = parsed + const cwd = options.cwd || process.cwd() + let configPath: string | undefined + let environment: string | undefined + let dryRun = false + let preview = false + let branchName: string | undefined + let deployMessage: string | undefined + let deployTag: string | undefined + let previewScopeName: string | undefined + let requireFreshProductionDeployment = false + let resolvedPreviewScopeName = process.env.DEVFLARE_PREVIEW_BRANCH?.trim() || undefined + const theme = createCliTheme(parsed.options) + + logLine(logger) + logLine(logger, `${yellowBold('deploy', theme)} ${dim('Shipping to Cloudflare', theme)}`) + + try { + deployTarget = resolveDeployTarget(parsed, { + requireExplicitTarget: options.requireExplicitDeployTarget === true + }) + resolvedParsed = applyResolvedDeployTarget(parsed, deployTarget) + configPath = resolvedParsed.options.config as string | undefined + environment = resolvedParsed.options.env as string | undefined + dryRun = resolvedParsed.options['dry-run'] === true + preview = deployTarget.mode === 'preview-upload' + branchName = resolvedParsed.options['branch-name'] as string | undefined + deployMessage = resolvedParsed.options.message as string | undefined + deployTag = resolvedParsed.options.tag as string | undefined + previewScopeName = branchName?.trim() || deployTarget.previewScopeRaw || undefined + resolvedPreviewScopeName = + previewScopeName || process.env.DEVFLARE_PREVIEW_BRANCH?.trim() || undefined + requireFreshProductionDeployment = !preview && shouldRequireFreshProductionDeployment() + + return await withTemporaryEnvironment(deployTarget.envOverrides, async () => { + resolvedPreviewScopeName = + previewScopeName || process.env.DEVFLARE_PREVIEW_BRANCH?.trim() || undefined + if (dryRun) { + const rawConfig = await loadConfig({ cwd, configFile: configPath }) + const config = await resolveConfigEnvVars( + resolveConfigForEnvironment(rawConfig, environment), + { + cwd, + configPath, + mode: 'build' + } + ) + const deploymentStrategy = applyDeploymentStrategy( + config, + { + environment, + preview, + branchName, + previewBranch: process.env.DEVFLARE_PREVIEW_BRANCH + } + ) + const wranglerConfig = compileBuildConfig(deploymentStrategy.config, undefined, { + alreadyResolved: true + }) + + logLine(logger, `${yellow('dry run', theme)} ${dim('Skipping actual deployment', theme)}`) + const deploymentStrategyMessage = describeDeploymentStrategy(deploymentStrategy) + if (deploymentStrategyMessage) { + logLine(logger, dim(deploymentStrategyMessage, theme)) + } + logLine(logger, dim('Would deploy with wrangler config:', theme)) + logLine(logger, stringifyConfig(wranglerConfig)) + + // C6 โ€” also render the resolved view (post-resource-resolution). + // Best-effort: if no Cloudflare credentials or the account + // cannot be reached, fall back to the build-config view above. + try { + const describeResult = await prepareConfigResourcesForDeploy(deploymentStrategy.config, { + environment, + describeOnly: true + }) + const resolvedWranglerConfig = compileConfig(describeResult.config) + logLine( + logger, + dim('Resolved view (would-create placeholders for missing resources):', theme) + ) + logLine(logger, stringifyConfig(resolvedWranglerConfig)) + const wouldCreate = [ + ...describeResult.created.kv.map((n) => `KV: ${n}`), + ...describeResult.created.d1.map((n) => `D1: ${n}`), + ...describeResult.created.r2.map((n) => `R2: ${n}`), + ...describeResult.created.queues.map((n) => `Queue: ${n}`) + ] + if (wouldCreate.length > 0) { + logLine(logger, dim(`Would create:\n - ${wouldCreate.join('\n - ')}`, theme)) + } + } catch (describeErr) { + logLine( + logger, + dim(`(resolved view unavailable: ${(describeErr as Error).message})`, theme) + ) + } + return { exitCode: 0 } + } + + const deps = await getDependencies() + const requestedBuildPath = resolvedParsed.options.build as string | undefined + const buildConfigPath = requestedBuildPath + ? await resolveBuildArtifactConfigPath(requestedBuildPath, cwd) + : (await prepareBuildArtifacts(resolvedParsed, logger, options)).deployConfigPath + const prepared = await prepareDeployConfig({ + cwd, + configPath, + environment, + buildConfigPath, + preview, + branchName, + logger, + force: resolvedParsed.options.force === true + }) + + const createdPreviewResourcesSummary = prepared.previewScopedResources + ? summarizeDeployResourceNames(prepared.previewScopedResources.created) + : null + if (createdPreviewResourcesSummary) { + logLine(logger, `Provisioned preview-scoped resources: ${createdPreviewResourcesSummary}`) + } + + const existingPreviewResourcesSummary = prepared.previewScopedResources + ? summarizeDeployResourceNames(prepared.previewScopedResources.existing) + : null + if (existingPreviewResourcesSummary) { + logLine(logger, `Reused preview-scoped resources: ${existingPreviewResourcesSummary}`) + } + + const createdDeployResourcesSummary = summarizeDeployResourceNames( + prepared.deployResources.created + ) + if (createdDeployResourcesSummary) { + logLine(logger, `Provisioned deploy resources: ${createdDeployResourcesSummary}`) + } + + const existingDeployResourcesSummary = summarizeDeployResourceNames( + prepared.deployResources.existing + ) + if (existingDeployResourcesSummary) { + logLine(logger, `Reused deploy resources: ${existingDeployResourcesSummary}`) + } + + for (const warning of prepared.previewScopedResources?.warnings ?? []) { + logger.warn(warning) + } + + for (const warning of prepared.deployResources.warnings) { + logger.warn(warning) + } + + if (requestedBuildPath) { + logLine(logger, `${dim('build', theme)} ${green(buildConfigPath, theme)}`) + } + + logLine(logger, `${dim('worker', theme)} ${green(prepared.config.name, theme)}`) + const localWranglerExecutable = await resolveLocalWranglerExecutable(cwd, deps.fs) + + const isBranchScopedPreviewDeployment = + !preview && + environment === 'preview' && + typeof resolvedPreviewScopeName === 'string' && + resolvedPreviewScopeName.length > 0 + + if (preview) { + logger.warn('Cloudflare preview uploads cannot be the first upload for a brand-new Worker.') + if ( + prepared.config.bindings?.durableObjects && + Object.keys(prepared.config.bindings.durableObjects).length > 0 + ) { + logger.warn( + 'Cloudflare does not currently generate preview URLs for Workers that implement Durable Objects.' + ) + } + if (prepared.config.migrations && prepared.config.migrations.length > 0) { + logger.warn( + 'Cloudflare versions upload does not currently support Durable Object migrations.' + ) + } + } + + // Deploy with wrangler + logLine( + logger, + dim( + preview ? 'Uploading preview version with Wranglerโ€ฆ' : 'Deploying with Wranglerโ€ฆ', + theme + ) + ) + const deployStartedAt = new Date() + + const wranglerOutputDirectory = join(cwd, '.devflare') + const wranglerOutputFilePath = join( + wranglerOutputDirectory, + `wrangler-output-${Date.now()}-${process.pid}.ndjson` + ) + await deps.fs.mkdir(wranglerOutputDirectory, { recursive: true }) + + const wranglerCommand = localWranglerExecutable ? 'node' : 'bunx' + const wranglerArgs = preview + ? localWranglerExecutable + ? [localWranglerExecutable, 'versions', 'upload'] + : ['wrangler', 'versions', 'upload'] + : localWranglerExecutable + ? [localWranglerExecutable, 'deploy'] + : ['wrangler', 'deploy'] + + wranglerArgs.push('--config', prepared.deployConfigPath) + + if (deployMessage?.trim()) { + wranglerArgs.push('--message', deployMessage.trim()) + } + + if (deployTag?.trim()) { + wranglerArgs.push('--tag', deployTag.trim()) + } + + const deployProc = await deps.exec.exec(wranglerCommand, wranglerArgs, { + cwd, + stdio: 'inherit', + env: { + ...process.env, + WRANGLER_OUTPUT_FILE_PATH: wranglerOutputFilePath, + FORCE_COLOR: process.env.FORCE_COLOR ?? '0' + } + }) + + let structuredOutput = '' + try { + structuredOutput = (await deps.fs.readFile(wranglerOutputFilePath, 'utf8')) as string + } catch { + structuredOutput = '' + } finally { + try { + await deps.fs.unlink(wranglerOutputFilePath) + } catch { + // Ignore cleanup failures. + } + } + + const parsedConsoleOutput = parseWranglerDeployOutput( + [deployProc.stdout, deployProc.stderr] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n') + ) + const parsedStructuredOutput = structuredOutput + ? parseWranglerStructuredOutput(structuredOutput) + : { urls: [], versionId: undefined, previewUrl: undefined } + const parsedOutput = mergeParsedWranglerDeployOutputs( + parsedConsoleOutput, + parsedStructuredOutput + ) + const workersDevUrl = parsedOutput.urls.find((url) => url.includes('workers.dev')) + const configuredAccountId = + normalizeCloudflareAccountId(prepared.config.accountId) ?? + normalizeCloudflareAccountId(process.env.CLOUDFLARE_ACCOUNT_ID) + let resolvedAccountId = configuredAccountId + let didAttemptAccountResolution = false + const versionRecoveryDiagnostics: string[] = [] + const ensureResolvedAccountId = async (): Promise => { + if (resolvedAccountId || didAttemptAccountResolution) { + return resolvedAccountId + } + + didAttemptAccountResolution = true + resolvedAccountId = await resolveDeployAccountId(undefined) + return resolvedAccountId + } + let resolvedVersionId = parsedOutput.versionId + let resolvedPreviewUrl = parsedOutput.previewUrl + let loggedVersionId = false + let verificationNote: string | undefined + + const persistDeployMetadata = async (input: { + status: 'success' | 'failure' + exitCode: number + error?: string + }): Promise => { + await writeDeployResultMetadata({ + status: input.status, + exitCode: input.exitCode, + workerName: prepared.config.name, + preview, + branchScopedPreview: isBranchScopedPreviewDeployment, + previewScope: resolvedPreviewScopeName, + versionId: resolvedVersionId, + previewUrl: resolvedPreviewUrl, + workersDevUrl, + verificationNote, + outputUrls: parsedOutput.urls, + structuredOutput, + ...(input.error ? { error: input.error } : {}) + }) + } + + if (deployProc.exitCode !== 0) { + await persistDeployMetadata({ + status: 'failure', + exitCode: 1, + error: deployProc.stderr || deployProc.stdout || 'Wrangler deploy failed' + }) + logger.error('Deployment failed') + return { exitCode: 1, output: structuredOutput } + } + + if (!preview && !resolvedVersionId && !isBranchScopedPreviewDeployment) { + resolvedAccountId = await ensureResolvedAccountId() + } + + if (isBranchScopedPreviewDeployment && !resolvedPreviewUrl) { + resolvedAccountId = await ensureResolvedAccountId() + } + + if (isBranchScopedPreviewDeployment && !resolvedPreviewUrl && resolvedAccountId) { + const workersSubdomain = await getWorkersSubdomain(resolvedAccountId) + if (workersSubdomain) { + resolvedPreviewUrl = formatWorkersDevUrl(prepared.config.name, workersSubdomain) + } + } + + if (!resolvedVersionId && resolvedAccountId) { + try { + resolvedVersionId = await resolveVersionIdFromLatestWorkerVersion({ + accountId: resolvedAccountId, + workerName: prepared.config.name, + preview: preview || isBranchScopedPreviewDeployment, + deployedAfter: deployStartedAt + }) + + logger.success(`Version ID: ${resolvedVersionId}`) + loggedVersionId = true + logLine(logger, dim('Resolved version id from Cloudflare version metadata', theme)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + versionRecoveryDiagnostics.push(`version lookup: ${message}`) + } + } + + if (isBranchScopedPreviewDeployment && !resolvedVersionId && resolvedAccountId) { + try { + const fallbackDeployment = await resolveVersionIdFromLatestProductionDeployment({ + accountId: resolvedAccountId, + workerName: prepared.config.name, + deployedAfter: deployStartedAt + }) + + resolvedVersionId = fallbackDeployment.versionId + logger.success(`Version ID: ${resolvedVersionId}`) + loggedVersionId = true + logLine( + logger, + dim( + `Resolved version id from Cloudflare deployment ${fallbackDeployment.deploymentId}`, + theme + ) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + versionRecoveryDiagnostics.push(`deployment lookup: ${message}`) + } + } + + if (!preview && !isBranchScopedPreviewDeployment && !resolvedVersionId && resolvedAccountId) { + try { + const fallbackDeployment = await resolveVersionIdFromLatestProductionDeployment({ + accountId: resolvedAccountId, + workerName: prepared.config.name, + deployedAfter: deployStartedAt + }) + + resolvedVersionId = fallbackDeployment.versionId + logger.success(`Version ID: ${resolvedVersionId}`) + loggedVersionId = true + logLine( + logger, + dim( + `Resolved version id from Cloudflare deployment ${fallbackDeployment.deploymentId}`, + theme + ) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + versionRecoveryDiagnostics.push(`deployment lookup: ${message}`) + // Fall back to the existing verification error below when Cloudflare does not + // expose a fresh deployment with version metadata yet. + } + } + + if (!preview && !isBranchScopedPreviewDeployment && !resolvedVersionId && resolvedAccountId) { + try { + const currentDeployment = await resolveVersionIdFromCurrentProductionDeployment({ + accountId: resolvedAccountId, + workerName: prepared.config.name + }) + + resolvedVersionId = currentDeployment.versionId + logger.success(`Version ID: ${resolvedVersionId}`) + loggedVersionId = true + const reuseMessage = `Cloudflare did not expose a fresh deployment or version after verification retries, and the current active deployment ${currentDeployment.deploymentId} still points at version ${resolvedVersionId}. This usually means the built Worker code and configuration were unchanged, so Cloudflare kept the existing live version.` + verificationNote = reuseMessage + + if (requireFreshProductionDeployment) { + await persistDeployMetadata({ + status: 'failure', + exitCode: 1, + error: reuseMessage + }) + logger.error( + `Deployment verification failed: ${reuseMessage} This run requires a fresh production deployment, so Devflare is treating the reused live version as a failure.` + ) + return { exitCode: 1, output: structuredOutput } + } + + logger.warn(`Deployment verification note: ${reuseMessage}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + versionRecoveryDiagnostics.push(`current production deployment: ${message}`) + } + } + + if (resolvedVersionId && !loggedVersionId) { + logger.success(`Version ID: ${resolvedVersionId}`) + } + + if (isBranchScopedPreviewDeployment && !resolvedPreviewUrl && resolvedAccountId) { + const workersSubdomain = await getWorkersSubdomain(resolvedAccountId) + if (workersSubdomain) { + resolvedPreviewUrl = formatWorkersDevUrl(prepared.config.name, workersSubdomain) + } + } + + if ((preview || isBranchScopedPreviewDeployment) && resolvedPreviewUrl) { + logLine(logger, `Preview URL: ${resolvedPreviewUrl}`) + } + + if (shouldVerifyDeployControlPlane()) { + if (!resolvedVersionId) { + const recoveryDetails = + versionRecoveryDiagnostics.length > 0 + ? ` Cloudflare fallback checks also failed: ${versionRecoveryDiagnostics.join(' | ')}` + : '' + await persistDeployMetadata({ + status: 'failure', + exitCode: 1, + error: `Wrangler did not return a Worker version id, so Devflare could not prove which version Cloudflare accepted.${recoveryDetails}` + }) + logger.error( + `Deployment verification failed: Wrangler did not return a Worker version id, so Devflare could not prove which version Cloudflare accepted.${recoveryDetails}` + ) + return { exitCode: 1, output: structuredOutput } + } + + resolvedAccountId = await ensureResolvedAccountId() + + if (!resolvedAccountId) { + await persistDeployMetadata({ + status: 'failure', + exitCode: 1, + error: 'Devflare could not resolve a Cloudflare account id.' + }) + logger.error( + 'Deployment verification failed: Devflare could not resolve a Cloudflare account id. Pass cloudflare-account-id to the action or set accountId in devflare.config.ts.' + ) + return { exitCode: 1, output: structuredOutput } + } + + try { + await verifyDeployControlPlane({ + accountId: resolvedAccountId, + workerName: prepared.config.name, + versionId: resolvedVersionId, + preview, + logger, + theme + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await persistDeployMetadata({ + status: 'failure', + exitCode: 1, + error: message + }) + logger.error(`Deployment verification failed: ${message}`) + return { exitCode: 1, output: structuredOutput } + } + } + + if (resolvedAccountId) { + const previewRegistryScope = isBranchScopedPreviewDeployment + ? deployTarget.previewScope + : undefined + const previewRegistryUrl = + preview || isBranchScopedPreviewDeployment ? resolvedPreviewUrl : undefined + + try { + await reconcilePreviewRegistry({ + accountId: resolvedAccountId, + workerName: prepared.config.name, + versionId: resolvedVersionId, + previewScope: previewRegistryScope, + previewUrl: previewRegistryUrl, + branchName: resolvedPreviewScopeName, + commitSha: process.env.GITHUB_SHA, + source: inferRecordSource(), + deploymentMessage: process.env.GITHUB_EVENT_NAME, + logger + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Devflare preview registry sync failed: ${message}`) + } + } + + await persistDeployMetadata({ + status: 'success', + exitCode: 0 + }) + logger.success('Deployed successfully!') + return { exitCode: 0, output: structuredOutput } + }) + } catch (error) { + await writeDeployResultMetadata({ + status: 'failure', + exitCode: 1, + preview, + branchScopedPreview: + !preview && environment === 'preview' && Boolean(resolvedPreviewScopeName), + previewScope: resolvedPreviewScopeName, + outputUrls: [], + ...(error instanceof Error ? { error: error.message } : { error: String(error) }) + }) + if (error instanceof Error) { + logger.error('Deployment failed:', error.message) + if (resolvedParsed.options.debug) { + logger.error(error.stack) + } + } + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/deploy/metadata.ts b/packages/devflare/src/cli/commands/deploy/metadata.ts new file mode 100644 index 0000000..58af2a1 --- /dev/null +++ b/packages/devflare/src/cli/commands/deploy/metadata.ts @@ -0,0 +1,32 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'pathe' + +export interface DeployResultMetadata { + status: 'success' | 'failure' + exitCode: number + workerName?: string + preview: boolean + branchScopedPreview: boolean + previewScope?: string + versionId?: string + previewUrl?: string + workersDevUrl?: string + verificationNote?: string + outputUrls: string[] + structuredOutput?: string + error?: string +} + +export function inferRecordSource(): 'cli' | 'github-action' { + return process.env.GITHUB_ACTIONS === 'true' ? 'github-action' : 'cli' +} + +export async function writeDeployResultMetadata(metadata: DeployResultMetadata): Promise { + const metadataPath = process.env.DEVFLARE_DEPLOY_METADATA_PATH?.trim() + if (!metadataPath) { + return + } + + await mkdir(dirname(metadataPath), { recursive: true }) + await writeFile(metadataPath, JSON.stringify(metadata, null, '\t'), 'utf8') +} diff --git a/packages/devflare/src/cli/commands/deploy/prepare.ts b/packages/devflare/src/cli/commands/deploy/prepare.ts new file mode 100644 index 0000000..65131eb --- /dev/null +++ b/packages/devflare/src/cli/commands/deploy/prepare.ts @@ -0,0 +1,333 @@ +import { mkdir, open, readFile, rm } from 'node:fs/promises' +import type { ConsolaInstance } from 'consola' +import { basename, dirname, isAbsolute, resolve } from 'pathe' +import { listWorkers } from '../../../cloudflare/account-workers' +import { + type DeployResourceNames, + type DevflareConfig, + type PrepareConfigResourcesForDeployResult, + ServiceBindingValidationError, + type WranglerConfig, + compileConfig, + loadConfig, + prepareConfigResourcesForDeploy, + readWranglerConfig, + resolveConfigEnvVars, + validateServiceBindings +} from '../../../config' +import { rebaseWranglerConfigPaths, writeWranglerConfig } from '../../../config/compiler' +import { preparePreviewScopedResourcesForDeploy } from '../../../config/preview-resources' +import { + compareManifests, + createBuildManifest, + formatDriftWarning, + readBuildManifest +} from '../../build-manifest' +import { applyDeploymentStrategy } from '../../deploy-strategy' +import { getPackageVersion } from '../../package-metadata' +import { logLine } from '../../ui' + +export interface PreparedDeployConfigResult { + config: DevflareConfig + deployConfigPath: string + previewScopedResources: Awaited> | null + deployResources: PrepareConfigResourcesForDeployResult + wranglerConfig: WranglerConfig +} + +export function summarizeDeployResourceNames(resources: DeployResourceNames): string | null { + const segments = [ + resources.kv.length > 0 ? `KV ${resources.kv.length}` : null, + resources.d1.length > 0 ? `D1 ${resources.d1.length}` : null, + resources.r2.length > 0 ? `R2 ${resources.r2.length}` : null, + resources.queues.length > 0 ? `Queues ${resources.queues.length}` : null, + resources.vectorize.length > 0 ? `Vectorize ${resources.vectorize.length}` : null, + resources.hyperdrive.length > 0 ? `Hyperdrive ${resources.hyperdrive.length}` : null + ].filter((segment): segment is string => segment !== null) + + return segments.length > 0 ? segments.join(' ยท ') : null +} + +async function readDeployRedirectPath(filePath: string): Promise { + const fs = await import('node:fs/promises') + + try { + const rawConfig = await fs.readFile(filePath, 'utf-8') + const parsed = JSON.parse(rawConfig) as { configPath?: unknown } + if (typeof parsed.configPath !== 'string' || parsed.configPath.length === 0) { + return null + } + + return resolve(dirname(filePath), parsed.configPath) + } catch { + return null + } +} + +export async function resolveBuildArtifactConfigPath( + buildPath: string, + cwd: string +): Promise { + const fs = await import('node:fs/promises') + const absoluteBuildPath = isAbsolute(buildPath) ? buildPath : resolve(cwd, buildPath) + + let stat: Awaited> + try { + stat = await fs.stat(absoluteBuildPath) + } catch { + throw new Error(`Could not find build artifact path: ${absoluteBuildPath}`) + } + + if (stat.isFile()) { + if (basename(absoluteBuildPath) === 'config.json') { + const redirectedConfigPath = await readDeployRedirectPath(absoluteBuildPath) + if (!redirectedConfigPath) { + throw new Error(`Build redirect ${absoluteBuildPath} did not contain a valid configPath.`) + } + + return redirectedConfigPath + } + + return absoluteBuildPath + } + + const candidates = [ + resolve(absoluteBuildPath, 'wrangler.jsonc'), + resolve(absoluteBuildPath, '.wrangler', 'deploy', 'config.json'), + resolve(absoluteBuildPath, 'config.json'), + resolve(absoluteBuildPath, '.devflare', 'build', 'wrangler.jsonc') + ] + + for (const candidatePath of candidates) { + try { + const candidateStat = await fs.stat(candidatePath) + if (!candidateStat.isFile()) { + continue + } + + if (basename(candidatePath) === 'config.json') { + const redirectedConfigPath = await readDeployRedirectPath(candidatePath) + if (redirectedConfigPath) { + return redirectedConfigPath + } + continue + } + + return candidatePath + } catch { + // Try the next candidate. + } + } + + throw new Error( + `Could not resolve a Wrangler build config from ${absoluteBuildPath}. Pass a .devflare/build directory, a generated wrangler.jsonc, or a .wrangler/deploy/config.json redirect.` + ) +} + +function withBuildArtifactPaths( + compiledConfig: WranglerConfig, + buildConfig: WranglerConfig +): WranglerConfig { + return { + ...compiledConfig, + ...(buildConfig.main ? { main: buildConfig.main } : {}), + ...(buildConfig.assets ? { assets: buildConfig.assets } : {}) + } +} + +export async function prepareDeployConfig(options: { + cwd: string + configPath?: string + environment?: string + buildConfigPath: string + preview: boolean + branchName?: string + logger?: ConsolaInstance + force?: boolean +}): Promise { + const loadedConfig = await loadConfig({ + cwd: options.cwd, + configFile: options.configPath + }) + const rawConfig = await resolveConfigEnvVars(loadedConfig, { + cwd: options.cwd, + configPath: options.configPath, + mode: 'build' + }) + + // R2: detect drift between the build artefact manifest and the current + // source/target. Fixes C5 (bindings drift), C8 (preview->production + // silent flip), C11 (cross-version artefact reuse). + const manifestDir = dirname(options.buildConfigPath) + const manifest = await readBuildManifest(manifestDir) + if (manifest) { + const currentManifest = createBuildManifest(rawConfig, { + devflareVersion: await getPackageVersion(), + intendedTarget: { + environment: options.environment, + preview: options.preview, + branchName: options.branchName + } + }) + const drift = compareManifests(manifest, currentManifest) + const warning = formatDriftWarning(drift) + if (warning && options.logger) { + if (options.force) { + logLine(options.logger, warning) + logLine(options.logger, 'Continuing because --force was passed.') + } else { + // Drift is a warning, not a hard error - surface it loudly so + // CI logs flag it, but don't block the deploy. Hard-blocking + // would be a behaviour change for existing pipelines that + // build then deploy with slightly different env vars. + logLine(options.logger, warning) + } + } + } + + const previewScopedResources = + options.environment === 'preview' + ? await preparePreviewScopedResourcesForDeploy(rawConfig, { + environment: options.environment + }) + : null + const deployResources = await prepareConfigResourcesForDeploy( + previewScopedResources?.config ?? rawConfig, + { + environment: options.environment, + accountId: previewScopedResources?.accountId, + cloudflare: previewScopedResources?.resourceResolutionCloudflare + } + ) + const deploymentStrategy = applyDeploymentStrategy(deployResources.config, { + environment: options.environment, + preview: options.preview, + branchName: options.branchName, + previewBranch: process.env.DEVFLARE_PREVIEW_BRANCH + }) + + // C16: deploy-time service-binding preflight. Surface typos in + // `bindings.services[*].service` before invoking Wrangler so users get + // a clear error pointing at the config instead of a runtime dispatch + // failure on the deployed worker. + const validationAccountId = + previewScopedResources?.accountId ?? + deploymentStrategy.config.accountId ?? + process.env.CLOUDFLARE_ACCOUNT_ID + if (validationAccountId) { + try { + await validateServiceBindings(deploymentStrategy.config, validationAccountId, { + listWorkers: (accountId) => listWorkers(accountId), + selfWorkerName: deploymentStrategy.config.name + }) + } catch (error) { + if (error instanceof ServiceBindingValidationError) { + throw error + } + // Non-validation failures (network/credentials) are non-fatal + // preflight noise - Wrangler's own deploy will surface auth + // problems clearly, and we don't want preflight to block when + // the validation account lookup itself fails. + } + } + + const buildWranglerConfig = await readWranglerConfig(options.buildConfigPath) + const compiledWranglerConfig = compileConfig(deploymentStrategy.config) + + // C4: write the resolved (ID-substituted) wrangler config to a sibling + // `.devflare/deploy/wrangler.jsonc` instead of overwriting the build + // artefact in place. Re-running `devflare deploy --build ` is + // non-destructive to the original build output. + const buildDir = dirname(options.buildConfigPath) + const deployArtefactDir = resolve(buildDir, '..', 'deploy') + await mkdir(deployArtefactDir, { recursive: true }) + const deployArtefactPath = resolve(deployArtefactDir, 'wrangler.jsonc') + + // `withBuildArtifactPaths` inherits `main`/`assets` from the build + // artefact when present (paths relative to `buildDir`), otherwise + // keeps the compiled values (paths relative to `options.cwd`). Rebase + // each path field relative to its true origin so wrangler can resolve + // the bundled entry-point and assets directory from the new + // `.devflare/deploy/wrangler.jsonc` location. + const compiledRebased = rebaseWranglerConfigPaths( + options.cwd, + deployArtefactDir, + compiledWranglerConfig + ) + const buildRebased = rebaseWranglerConfigPaths(buildDir, deployArtefactDir, buildWranglerConfig) + const wranglerConfig = withBuildArtifactPaths(compiledRebased, buildRebased) + + // C10: serialize concurrent deploys against the same artefact. Exclusive + // `wx` lock file with bounded wait so two `devflare deploy` invocations + // targeting the same `.devflare/deploy/` cannot tear each other's writes. + const lockPath = resolve(deployArtefactDir, '.lock') + const lockHandle = await acquireDeployArtefactLock(lockPath) + try { + await writeWranglerConfig(deployArtefactDir, wranglerConfig, 'wrangler.jsonc') + } finally { + await releaseDeployArtefactLock(lockHandle, lockPath) + } + + return { + config: deploymentStrategy.config, + deployConfigPath: deployArtefactPath, + previewScopedResources, + deployResources, + wranglerConfig + } +} + +/** + * C10 โ€” bounded-wait exclusive lock around the deploy artefact directory. + * + * Uses `open(path, 'wx')` (O_EXCL) which atomically fails when the file + * already exists, so the only way to acquire the lock is to be the process + * that successfully created it. Stale locks (older than 60s) are forcibly + * cleared so a crashed deploy cannot wedge subsequent runs. + */ +async function acquireDeployArtefactLock( + lockPath: string, + options: { maxWaitMs?: number; staleAfterMs?: number } = {} +): Promise<{ close: () => Promise }> { + const maxWaitMs = options.maxWaitMs ?? 30_000 + const staleAfterMs = options.staleAfterMs ?? 60_000 + const pollMs = 100 + const start = Date.now() + while (true) { + try { + const handle = await open(lockPath, 'wx') + await handle.writeFile(`${process.pid}\n${Date.now()}`) + return handle + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + try { + const existing = await readFile(lockPath, 'utf-8') + const ts = Number.parseInt(existing.split('\n')[1] ?? '0', 10) + if (Number.isFinite(ts) && Date.now() - ts > staleAfterMs) { + await rm(lockPath, { force: true }) + continue + } + } catch { + continue + } + if (Date.now() - start > maxWaitMs) { + throw new Error( + `Timed out waiting for deploy artefact lock at ${lockPath}. Another \`devflare deploy\` may be running against the same artefact directory.` + ) + } + await new Promise((r) => setTimeout(r, pollMs)) + } + } +} + +async function releaseDeployArtefactLock( + handle: { close: () => Promise }, + lockPath: string +): Promise { + try { + await handle.close() + } catch { + // Already closed. + } + await rm(lockPath, { force: true }) +} diff --git a/packages/devflare/src/cli/commands/deploy/runtime.ts b/packages/devflare/src/cli/commands/deploy/runtime.ts new file mode 100644 index 0000000..44c6d4c --- /dev/null +++ b/packages/devflare/src/cli/commands/deploy/runtime.ts @@ -0,0 +1,31 @@ +import { resolvePackageSpecifier } from '../../../utils/resolve-package' +import { getDependencies } from '../../dependencies' + +export async function getCurrentGitBranch(cwd: string): Promise { + const deps = await getDependencies() + const gitResult = await deps.exec.exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) + if (gitResult.exitCode !== 0) { + return null + } + + const branchName = gitResult.stdout.trim() + if (!branchName || branchName === 'HEAD') { + return null + } + + return branchName +} + +export async function resolveLocalWranglerExecutable( + cwd: string, + fs: Awaited>['fs'] +): Promise { + const wranglerExecutablePath = resolvePackageSpecifier('wrangler/bin/wrangler.js', cwd) + + try { + await fs.access(wranglerExecutablePath) + return wranglerExecutablePath + } catch { + return null + } +} diff --git a/packages/devflare/src/cli/commands/deploy/verification.ts b/packages/devflare/src/cli/commands/deploy/verification.ts new file mode 100644 index 0000000..70b7d8e --- /dev/null +++ b/packages/devflare/src/cli/commands/deploy/verification.ts @@ -0,0 +1,302 @@ +import type { ConsolaInstance } from 'consola' +import { + getPrimaryAccount, + getWorkerVersionDetail, + listWorkerDeployments, + listWorkerVersions +} from '../../../cloudflare/account' +import { getEffectiveAccountId } from '../../../cloudflare/preferences' +import { type createCliTheme, dim, logLine } from '../../ui' + +export function shouldVerifyDeployControlPlane(): boolean { + const configured = process.env.DEVFLARE_VERIFY_DEPLOYMENT?.trim().toLowerCase() + if (!configured) { + return false + } + + return !['0', 'false', 'no', 'off'].includes(configured) +} + +export function shouldRequireFreshProductionDeployment(): boolean { + const configured = process.env.DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT?.trim().toLowerCase() + if (!configured) { + return false + } + + return !['0', 'false', 'no', 'off'].includes(configured) +} + +function getDeployVerificationSettings(): { attempts: number; delayMs: number } { + const attempts = Number.parseInt(process.env.DEVFLARE_VERIFY_DEPLOYMENT_ATTEMPTS ?? '', 10) + const delayMs = Number.parseInt(process.env.DEVFLARE_VERIFY_DEPLOYMENT_DELAY_MS ?? '', 10) + + return { + attempts: Number.isFinite(attempts) && attempts > 0 ? attempts : 5, + delayMs: Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 1500 + } +} + +const DEPLOYMENT_LOOKBACK_TOLERANCE_MS = 2 * 60 * 1000 + +export function normalizeCloudflareAccountId(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) { + return undefined + } + + return /^[a-f0-9]{32}$/i.test(trimmed) ? trimmed : undefined +} + +async function waitForDeployVerification(delayMs: number): Promise { + if (delayMs <= 0) { + return + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +async function retryDeployVerification( + description: string, + operation: () => Promise +): Promise { + const { attempts, delayMs } = getDeployVerificationSettings() + let lastError: unknown + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await operation() + } catch (error) { + lastError = error + if (attempt < attempts) { + await waitForDeployVerification(delayMs) + } + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError) + throw new Error( + `Cloudflare could not verify ${description} after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${message}` + ) +} + +export async function resolveDeployAccountId( + preferredAccountId: string | undefined +): Promise { + if (preferredAccountId !== undefined) { + return normalizeCloudflareAccountId(preferredAccountId) + } + + const apiToken = process.env.CLOUDFLARE_API_TOKEN?.trim() + const apiKey = process.env.CLOUDFLARE_API_KEY?.trim() + const apiEmail = process.env.CLOUDFLARE_EMAIL?.trim() + if (!apiToken && !(apiKey && apiEmail)) { + return undefined + } + + try { + const primaryAccount = await getPrimaryAccount() + if (!primaryAccount) { + return undefined + } + + const effective = await getEffectiveAccountId(primaryAccount.id) + return normalizeCloudflareAccountId(effective.accountId) + } catch { + return undefined + } +} + +function selectDeploymentVersionId(deployment: { + versions: Array<{ + percentage: number + versionId: string + }> +}): string | undefined { + return ( + deployment.versions.find((version) => version.percentage === 100)?.versionId ?? + deployment.versions[0]?.versionId + ) +} + +function getWorkerVersionTimestamp(version: { + metadata: { + createdOn?: Date + modifiedOn?: Date + } +}): Date | undefined { + return version.metadata.modifiedOn ?? version.metadata.createdOn +} + +async function resolveVersionIdFromLatestDeployment(options: { + accountId: string + workerName: string + verificationDescription: string + deploymentLabel: 'Latest deployment' | 'Current deployment' + deployedAfter?: Date +}): Promise<{ + deploymentId: string + versionId: string +}> { + return retryDeployVerification(options.verificationDescription, async () => { + const deployments = await listWorkerDeployments(options.accountId, options.workerName) + const latestDeployment = [...deployments].sort( + (a, b) => b.createdOn.getTime() - a.createdOn.getTime() + )[0] + + if (!latestDeployment) { + throw new Error(`No deployments were found for Worker "${options.workerName}".`) + } + + if ( + options.deployedAfter && + latestDeployment.createdOn.getTime() < + options.deployedAfter.getTime() - DEPLOYMENT_LOOKBACK_TOLERANCE_MS + ) { + throw new Error( + `${options.deploymentLabel} ${latestDeployment.id} was created before this deploy started.` + ) + } + + const versionId = selectDeploymentVersionId(latestDeployment) + if (!versionId) { + throw new Error( + `${options.deploymentLabel} ${latestDeployment.id} does not reference any version ids.` + ) + } + + return { + deploymentId: latestDeployment.id, + versionId + } + }) +} + +export async function resolveVersionIdFromLatestWorkerVersion(options: { + accountId: string + workerName: string + preview: boolean + deployedAfter: Date +}): Promise { + return retryDeployVerification( + `the latest ${options.preview ? 'preview ' : ''}version for Worker "${options.workerName}"`, + async () => { + const versions = await listWorkerVersions(options.accountId, options.workerName) + const latestVersion = [...versions] + .filter((version) => version.id) + .filter((version) => version.metadata.hasPreview === options.preview) + .sort((a, b) => { + const left = getWorkerVersionTimestamp(a)?.getTime() ?? 0 + const right = getWorkerVersionTimestamp(b)?.getTime() ?? 0 + return right - left + })[0] + + if (!latestVersion) { + throw new Error( + `No ${options.preview ? 'preview ' : ''}versions were found for Worker "${options.workerName}".` + ) + } + + const latestVersionTimestamp = getWorkerVersionTimestamp(latestVersion) + if (!latestVersionTimestamp) { + throw new Error(`Latest version ${latestVersion.id} did not include a creation timestamp.`) + } + + if ( + latestVersionTimestamp.getTime() < + options.deployedAfter.getTime() - DEPLOYMENT_LOOKBACK_TOLERANCE_MS + ) { + throw new Error( + `Latest version ${latestVersion.id} was created before this deploy started.` + ) + } + + return latestVersion.id + } + ) +} + +export async function resolveVersionIdFromLatestProductionDeployment(options: { + accountId: string + workerName: string + deployedAfter: Date +}): Promise<{ + deploymentId: string + versionId: string +}> { + return resolveVersionIdFromLatestDeployment({ + accountId: options.accountId, + workerName: options.workerName, + verificationDescription: `the latest deployment for Worker "${options.workerName}"`, + deploymentLabel: 'Latest deployment', + deployedAfter: options.deployedAfter + }) +} + +export async function resolveVersionIdFromCurrentProductionDeployment(options: { + accountId: string + workerName: string +}): Promise<{ + deploymentId: string + versionId: string +}> { + return resolveVersionIdFromLatestDeployment({ + accountId: options.accountId, + workerName: options.workerName, + verificationDescription: `the current active deployment for Worker "${options.workerName}"`, + deploymentLabel: 'Current deployment' + }) +} + +export async function verifyDeployControlPlane(options: { + accountId: string + workerName: string + versionId: string + preview: boolean + logger: ConsolaInstance + theme: ReturnType +}): Promise { + logLine(options.logger, dim('Verifying Cloudflare control-plane stateโ€ฆ', options.theme)) + + await retryDeployVerification(`Worker version ${options.versionId}`, async () => { + const version = await getWorkerVersionDetail( + options.accountId, + options.workerName, + options.versionId + ) + + if (!version.id) { + throw new Error(`Cloudflare returned an empty version record for ${options.versionId}.`) + } + + return version + }) + + if (options.preview) { + options.logger.success( + `Verified preview upload in Cloudflare control plane for version ${options.versionId}` + ) + return + } + + const deployment = await retryDeployVerification( + `a deployment that references version ${options.versionId}`, + async () => { + const deployments = await listWorkerDeployments(options.accountId, options.workerName) + const match = deployments.find((item) => + item.versions.some((version) => version.versionId === options.versionId) + ) + + if (!match) { + throw new Error( + `No deployment for Worker "${options.workerName}" references version ${options.versionId} yet.` + ) + } + + return match + } + ) + + options.logger.success( + `Verified Cloudflare deployment ${deployment.id} for version ${options.versionId}` + ) +} diff --git a/packages/devflare/src/cli/commands/dev.ts b/packages/devflare/src/cli/commands/dev.ts new file mode 100644 index 0000000..79c90cd --- /dev/null +++ b/packages/devflare/src/cli/commands/dev.ts @@ -0,0 +1,312 @@ +// ============================================================================= +// Dev Command โ€” Development Server +// ============================================================================= +// +// Starts a worker-only dev server by default and enables Vite when the current +// package provides a local vite.config.*. +// +// How it works: +// 1. Vite runs in dev mode (full HMR for frontend) +// 2. Miniflare runs with Gateway Worker + DO Workers +// 3. Rolldown watches DO files and rebuilds on change +// 4. When DO files change: Rolldown rebuilds โ†’ Miniflare hot reloads via setOptions() +// 5. Bridge connects Node.js/Vite to Miniflare via WebSocket RPC +// +// All bindings work: KV, D1, R2, DOs (including WebSockets), Queues, AI, Browser +// +// Logging Flags: +// - `--log` โ†’ Log all output to `.log-{datetime}` file AND terminal +// - `--log-temp` โ†’ Log all output to `.log` file (overwritten) AND terminal +// ============================================================================= + +import { createConsola, type ConsolaInstance } from 'consola' +import { relative, resolve } from 'pathe' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { loadConfig } from '../../config/loader' +import { createDevServer } from '../../dev-server' +import { detectViteProject } from '../../dev-server/vite-utils' +import { resolveEffectiveViteProject } from '../../vite' +import { createCliTheme, cyanBold, dim, logLine, yellow } from '../ui' + +// ============================================================================= +// Logging System +// ============================================================================= + +interface LogWriter { + path: string + write: (data: string | Buffer, source?: 'vite' | 'miniflare' | 'rolldown') => void + close: () => void +} + +function readStringOption(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function parsePort(value: string, source: string): number { + const port = Number(value) + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${source} must be an integer between 1 and 65535`) + } + return port +} + +function resolveSinglePort( + runtimePort: string | undefined, + bridgePort: string | undefined, + source: string +): number | undefined { + if (runtimePort && bridgePort && runtimePort !== bridgePort) { + throw new Error( + `Conflicting Devflare runtime ports: ${source} runtime port is ${runtimePort}, but bridge port is ${bridgePort}. Use one value for both.` + ) + } + + const value = runtimePort ?? bridgePort + return value ? parsePort(value, source) : undefined +} + +export function resolveDevRuntimePort( + options: Record, + env: NodeJS.ProcessEnv = process.env +): number { + return resolveSinglePort( + readStringOption(options['runtime-port']), + readStringOption(options['bridge-port']), + 'CLI' + ) + ?? resolveSinglePort( + env.DEVFLARE_RUNTIME_PORT, + env.DEVFLARE_BRIDGE_PORT, + 'environment' + ) + ?? 8787 +} + +/** + * Create a log writer that writes to both terminal and file + */ +async function createLogWriter( + cwd: string, + options: { log?: boolean; logTemp?: boolean } +): Promise { + if (!options.log && !options.logTemp) { + return null + } + + const fs = await import('node:fs') + + // Determine log file path + let logPath: string + if (options.logTemp) { + logPath = resolve(cwd, '.log') + } else { + const now = new Date() + const timestamp = now.toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .slice(0, 19) + logPath = resolve(cwd, `.log-${timestamp}`) + } + + // Open file stream + const fileStream = fs.createWriteStream(logPath, { flags: 'w' }) + + // ANSI escape code regex for stripping colors from file output + const ansiRegex = /\x1b\[[0-9;]*m/g + + return { + path: logPath, + write(data: string | Buffer, source?: 'vite' | 'miniflare' | 'rolldown') { + const str = typeof data === 'string' ? data : data.toString() + if (!str.trim()) return + + // Format with source prefix for file + const timestamp = new Date().toISOString().slice(11, 23) + const prefix = source ? `[${timestamp}][${source.toUpperCase()}] ` : `[${timestamp}] ` + + // Write to file (strip ANSI colors) + const cleanStr = str.replace(ansiRegex, '') + fileStream.write(prefix + cleanStr + (cleanStr.endsWith('\n') ? '' : '\n')) + }, + close() { + fileStream.end() + } + } +} + +export async function runDevCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd || (parsed.options.cwd as string) || process.cwd() + const configPath = parsed.options.config as string | undefined + const port = parsed.options.port as string | undefined + const logEnabled = parsed.options.log === true + const logTempEnabled = parsed.options['log-temp'] === true + const persistEnabled = parsed.options.persist === true + const debugEnabled = parsed.options.debug === true || process.env.DEVFLARE_DEBUG === 'true' + const verbose = parsed.options.verbose === true || debugEnabled + const theme = createCliTheme(parsed.options) + let miniflarePort: number + + try { + miniflarePort = resolveDevRuntimePort(parsed.options) + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)) + return { exitCode: 1 } + } + + const config = await loadConfig({ cwd, configFile: configPath }) + const viteProject = resolveEffectiveViteProject( + await detectViteProject(cwd), + config + ) + + // Create log writer if logging is enabled + const logWriter = await createLogWriter(cwd, { + log: logEnabled, + logTemp: logTempEnabled + }) + + if (logWriter) { + const logFile = relative(cwd, logWriter.path) || '.log' + logLine(logger, `${dim('logging', theme)} ${logFile}`) + } + + // Create a custom logger that also writes to file + const devLogger = createConsola({ + level: verbose ? 4 : 3 + }) + + // Wrap logger to also write to file + if (logWriter) { + const wrapLog = (original: typeof devLogger.info, prefix = '') => { + return (message: unknown, ...args: unknown[]) => { + original(message, ...args) + const formatted = prefix + ? `${prefix} ${[message, ...args].join(' ')}` + : [message, ...args].join(' ') + logWriter.write(formatted) + } + } + + // Override methods using Object.assign to preserve 'raw' and other properties + Object.assign(devLogger.log, wrapLog(devLogger.log.bind(devLogger))) + Object.assign(devLogger.info, wrapLog(devLogger.info.bind(devLogger))) + Object.assign(devLogger.error, wrapLog(devLogger.error.bind(devLogger), '[ERROR]')) + Object.assign(devLogger.warn, wrapLog(devLogger.warn.bind(devLogger), '[WARN]')) + Object.assign(devLogger.success, wrapLog(devLogger.success.bind(devLogger), '[OK]')) + Object.assign(devLogger.debug, wrapLog(devLogger.debug.bind(devLogger), '[DEBUG]')) + } + + try { + logLine(logger) + if (viteProject.shouldStartVite) { + logLine(logger, `${cyanBold('dev', theme)} ${dim('Unified Dev Server', theme)}`) + logLine(logger, ' โ”œโ”€ Vite: Full HMR for frontend') + logLine(logger, ' โ”œโ”€ Miniflare: All Cloudflare bindings') + logLine(logger, ' โ”œโ”€ Rolldown: Worker + DO bundling with watch') + logLine(logger, ' โ””โ”€ Bridge: WebSocket RPC connection') + } else { + logLine(logger, `${cyanBold('dev', theme)} ${dim('Worker Dev Server', theme)}`) + logLine(logger, ' โ”œโ”€ Miniflare: All Cloudflare bindings') + logLine(logger, ' โ”œโ”€ Rolldown: Worker + DO bundling with watch') + logLine(logger, ' โ””โ”€ Vite: Disabled (no effective Vite config found)') + + if (viteProject.wantsViteIntegration) { + logger.warn('Vite-related settings were detected, but no effective Vite config was available') + logger.warn('Skipping Vite startup and running in worker-only mode') + } + } + logLine(logger) + + // Create unified dev server + const devServer = createDevServer({ + cwd, + configPath, + vitePort: port ? parseInt(port, 10) : 5173, + miniflarePort, + enableVite: viteProject.shouldStartVite, + persist: persistEnabled, + logger: devLogger, + verbose, + debug: debugEnabled + }) + + // Handle graceful shutdown + let isCleaningUp = false + const cleanupHandlers = new Map void>() + + const removeCleanupHandlers = () => { + for (const [event, handler] of cleanupHandlers) { + process.off(event, handler) + } + cleanupHandlers.clear() + } + + const cleanup = async (exitCode: number, reason?: unknown) => { + if (isCleaningUp) { + return + } + + isCleaningUp = true + removeCleanupHandlers() + + if (reason) { + const message = reason instanceof Error + ? reason.stack ?? reason.message + : String(reason) + logger.error(message) + } + + logLine(logger) + logLine(logger, `${yellow('dev', theme)} ${dim('Shutting downโ€ฆ', theme)}`) + + try { + await devServer.stop() + } finally { + logWriter?.close() + process.exit(exitCode) + } + } + + const registerCleanupHandler = (event: string, handler: (...args: any[]) => void) => { + cleanupHandlers.set(event, handler) + process.on(event, handler) + } + + registerCleanupHandler('SIGINT', () => { + void cleanup(0) + }) + registerCleanupHandler('SIGTERM', () => { + void cleanup(0) + }) + registerCleanupHandler('SIGHUP', () => { + void cleanup(0) + }) + registerCleanupHandler('uncaughtException', (error: unknown) => { + void cleanup(1, error) + }) + registerCleanupHandler('unhandledRejection', (reason: unknown) => { + void cleanup(1, reason) + }) + + // Start the server + await devServer.start() + + // Keep process running + await new Promise(() => { }) + + return { exitCode: 0 } + } catch (error) { + logWriter?.close() + if (error instanceof Error) { + logger.error('Dev server failed:', error.message) + if (verbose) { + logger.error(error.stack) + } + } + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/doctor.ts b/packages/devflare/src/cli/commands/doctor.ts new file mode 100644 index 0000000..d35aafb --- /dev/null +++ b/packages/devflare/src/cli/commands/doctor.ts @@ -0,0 +1,276 @@ +// ============================================================================= +// Doctor Command โ€” Check project configuration +// ============================================================================= + +import { type ConsolaInstance } from 'consola' +import { basename, dirname, relative, resolve } from 'pathe' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { resolveConfigPath, loadConfig } from '../../config/loader' +import { getDependencies } from '../dependencies' +import { getGeneratedArtifactPaths } from '../generated-artifacts' +import { detectViteProject } from '../../dev-server/vite-utils' +import { formatSupportedConfigFilenames, resolveConfigCandidatePath } from '../config-path' +import { getPackageVersion } from '../package-metadata' +import { bold, createCliTheme, dim, green, logLine, red, yellow } from '../ui' + +interface CheckResult { + name: string + status: 'pass' | 'warn' | 'fail' + message: string +} + +export async function runDoctorCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd || process.cwd() + const theme = createCliTheme(parsed.options) + const requestedConfigOption = parsed.options.config as string | undefined + const scope = (parsed.options.scope as string | undefined) ?? 'all' + if (!['all', 'local', 'deploy'].includes(scope)) { + logger.error(`Unsupported doctor scope: ${scope}`) + logger.info('Supported scopes: all, local, deploy') + return { exitCode: 1 } + } + + const requestedConfigPath = requestedConfigOption + ? resolve(cwd, requestedConfigOption) + : cwd + const checks: CheckResult[] = [] + const { fs } = await getDependencies() + const viteProject = await detectViteProject(cwd, fs as unknown as Parameters[1]) + + logLine(logger) + logLine(logger, `${bold('doctor', theme)} ${dim('Running diagnostics', theme)}`) + logLine(logger) + + // Check 1: config file exists + const configPath = await resolveConfigCandidatePath(requestedConfigPath) + if (configPath) { + checks.push({ + name: 'Config File', + status: 'pass', + message: `Found: ${configPath}` + }) + + // Check 1b: Config is valid + try { + const config = requestedConfigOption + ? await loadConfig({ + cwd: dirname(configPath), + configFile: basename(configPath) + }) + : await loadConfig({ cwd }) + checks.push({ + name: 'Config Valid', + status: 'pass', + message: `Project: ${config.name}` + }) + } catch (error) { + checks.push({ + name: 'Config Valid', + status: 'fail', + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } else { + checks.push({ + name: 'Config File', + status: 'fail', + message: `${formatSupportedConfigFilenames()} not found. Run \`devflare init\` to create one.` + }) + } + + // Check 2: package.json exists + const packageJsonPath = resolve(cwd, 'package.json') + try { + await fs.access(packageJsonPath) + const content = await fs.readFile(packageJsonPath, 'utf-8') + const pkg = JSON.parse(content) + + checks.push({ + name: 'package.json', + status: 'pass', + message: `Found: ${pkg.name || 'unnamed'}` + }) + + // Check 2b: Required dependencies + const deps = { ...pkg.dependencies, ...pkg.devDependencies } + + if (deps.devflare) { + const resolvedVersion = await getPackageVersion() + checks.push({ + name: 'devflare dep', + status: 'pass', + message: `package.json: ${deps.devflare}, resolved: ${resolvedVersion}` + }) + } else { + checks.push({ + name: 'devflare dep', + status: 'warn', + message: 'devflare not in dependencies' + }) + } + } catch { + checks.push({ + name: 'package.json', + status: 'fail', + message: 'package.json not found' + }) + } + + if (viteProject.wantsViteIntegration) { + checks.push({ + name: 'Vite Integration', + status: 'pass', + message: 'Enabled for this package' + }) + + if (viteProject.hasLocalViteDependency) { + checks.push({ + name: 'vite dep', + status: 'pass', + message: 'Found in package.json' + }) + } else { + checks.push({ + name: 'vite dep', + status: 'warn', + message: 'Not declared in this package.json (workspace-hoisted installs may still work)' + }) + } + + if (viteProject.hasLocalCloudflareVitePluginDependency) { + checks.push({ + name: '@cloudflare/vite-plugin', + status: 'pass', + message: 'Found in package.json' + }) + } else { + checks.push({ + name: '@cloudflare/vite-plugin', + status: 'pass', + message: 'Optional: not declared in this package.json. Install it only when your Vite config calls the Cloudflare Vite plugin directly.' + }) + } + + if (viteProject.viteConfigPath) { + checks.push({ + name: 'Vite Config', + status: 'pass', + message: `Found: ${viteProject.viteConfigPath}` + }) + } else { + checks.push({ + name: 'Vite Config', + status: 'warn', + message: 'No vite.config found. Create one with @cloudflare/vite-plugin' + }) + } + } else { + checks.push({ + name: 'Vite Integration', + status: 'pass', + message: 'Not enabled for this package (worker-only mode)' + }) + } + + // Check 4: tsconfig.json exists + try { + await fs.access(resolve(cwd, 'tsconfig.json')) + checks.push({ + name: 'tsconfig.json', + status: 'pass', + message: 'Found' + }) + } catch { + checks.push({ + name: 'tsconfig.json', + status: 'warn', + message: 'tsconfig.json not found' + }) + } + + // Check 5: generated Wrangler config artifacts + const artifactPaths = getGeneratedArtifactPaths(cwd) + + if (scope === 'all' || scope === 'local') { + try { + await fs.access(artifactPaths.devWranglerConfigPath) + checks.push({ + name: 'Generated dev config', + status: 'pass', + message: `Found: ${relative(cwd, artifactPaths.devWranglerConfigPath)}` + }) + } catch { + checks.push({ + name: 'Generated dev config', + status: 'warn', + message: 'Local readiness: not found. Run `devflare dev` or start `devflare/vite` to populate `.devflare/wrangler.jsonc`.' + }) + } + } + + if (scope === 'all' || scope === 'deploy') { + try { + await fs.access(artifactPaths.buildWranglerConfigPath) + checks.push({ + name: 'Generated deploy config', + status: 'pass', + message: `Found: ${relative(cwd, artifactPaths.buildWranglerConfigPath)}` + }) + } catch { + checks.push({ + name: 'Generated deploy config', + status: 'warn', + message: 'Deploy readiness: not found. Run `devflare build` or `devflare deploy` to generate `.devflare/build/wrangler.jsonc`.' + }) + } + + try { + await fs.access(artifactPaths.deployRedirectPath) + checks.push({ + name: 'Wrangler deploy redirect', + status: 'pass', + message: `Found: ${relative(cwd, artifactPaths.deployRedirectPath)}` + }) + } catch { + checks.push({ + name: 'Wrangler deploy redirect', + status: 'warn', + message: 'Deploy readiness: not found. Run `devflare build` or `devflare deploy` to generate `.wrangler/deploy/config.json`.' + }) + } + } + + // Output results + let hasFailures = false + let hasWarnings = false + + for (const check of checks) { + const icon = check.status === 'pass' ? 'โœ“' : check.status === 'warn' ? 'โš ' : 'โœ—' + if (check.status === 'pass') { + logLine(logger, `${green(icon, theme)} ${bold(check.name, theme)}${dim(' โ€” ', theme)}${check.message}`) + } else if (check.status === 'warn') { + logLine(logger, `${yellow(icon, theme)} ${bold(check.name, theme)}${dim(' โ€” ', theme)}${check.message}`) + hasWarnings = true + } else { + logLine(logger, `${red(icon, theme)} ${bold(check.name, theme)}${dim(' โ€” ', theme)}${check.message}`) + hasFailures = true + } + } + + logLine(logger) + + if (hasFailures) { + logger.error('Some checks failed. Please fix the issues above.') + return { exitCode: 1 } + } else if (hasWarnings) { + logger.warn('All critical checks passed, but there are warnings.') + return { exitCode: 0 } + } else { + logger.success('All checks passed!') + return { exitCode: 0 } + } +} diff --git a/packages/devflare/src/cli/commands/init.ts b/packages/devflare/src/cli/commands/init.ts new file mode 100644 index 0000000..cffaae5 --- /dev/null +++ b/packages/devflare/src/cli/commands/init.ts @@ -0,0 +1,218 @@ +// ============================================================================= +// Init Command โ€” Create new devflare project +// ============================================================================= + +import { type ConsolaInstance } from 'consola' +import { resolve, join } from 'pathe' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { getDependencies, type FileSystem } from '../dependencies' +import { getInitDependencyVersions } from '../package-metadata' +import { createCliTheme, cyanBold, dim, green, logLine } from '../ui' + +/** + * Template configuration for project scaffolding + */ +interface ProjectTemplate { + name: string + description: string + files: Record +} + +// ============================================================================= +// Templates +// ============================================================================= + +const MINIMAL_TEMPLATE: ProjectTemplate = { + name: 'minimal', + description: 'Minimal starter with single handler', + files: { + 'devflare.config.ts': `import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: '{{PROJECT_NAME}}', + compatibilityDate: '${new Date().toISOString().split('T')[0]}', + files: { + fetch: 'src/fetch.ts' + } +}) +`, + 'src/fetch.ts': `import type { FetchEvent } from 'devflare/runtime' + +export async function fetch({ url }: FetchEvent): Promise { + return new Response( + url.pathname === '/' + ? 'Hello from Devflare' + : \`Hello from Devflare: \${url.pathname}\` + ) +} +`, + 'package.json': `{ + "name": "{{PROJECT_NAME}}", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "devflare dev", + "build": "devflare build", + "deploy": "devflare deploy", + "types": "devflare types" + }, + "devDependencies": { + "@cloudflare/workers-types": "{{WORKERS_TYPES_VERSION}}", + "devflare": "{{DEVFLARE_VERSION}}", + "typescript": "{{TYPESCRIPT_VERSION}}", + "wrangler": "{{WRANGLER_VERSION}}" + } +} +`, + 'tsconfig.json': `{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*", "env.d.ts", "devflare.config.ts"] +} +` + } +} + +const API_TEMPLATE: ProjectTemplate = { + name: 'api', + description: 'API starter with request-wide middleware', + files: { + 'devflare.config.ts': `import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: '{{PROJECT_NAME}}', + compatibilityDate: '${new Date().toISOString().split('T')[0]}', + files: { + fetch: 'src/fetch.ts' + } +}) +`, + 'src/fetch.ts': `import { sequence } from 'devflare/runtime' +import { corsHandle } from './middleware/cors' +import { appFetch } from './app' + +export const handle = sequence(corsHandle, appFetch) +`, + 'src/middleware/cors.ts': `import type { FetchEvent, ResolveFetch } from 'devflare/runtime' + +export async function corsHandle(event: FetchEvent, resolve: ResolveFetch): Promise { + // Handle preflight + if (event.request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }) + } + + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('Access-Control-Allow-Origin', '*') + return next +} +`, + 'src/app.ts': `import type { FetchEvent } from 'devflare/runtime' + +export async function appFetch({ url }: FetchEvent): Promise { + if (url.pathname === '/api/health') { + return Response.json({ status: 'ok' }) + } + + if (url.pathname.startsWith('/api/')) { + return Response.json({ error: 'Not found' }, { status: 404 }) + } + + return new Response('Not Found', { status: 404 }) +} +`, + 'package.json': MINIMAL_TEMPLATE.files['package.json'], + 'tsconfig.json': MINIMAL_TEMPLATE.files['tsconfig.json'] + } +} + +const TEMPLATES: Record = { + minimal: MINIMAL_TEMPLATE, + api: API_TEMPLATE +} + +// ============================================================================= +// Init Command Implementation +// ============================================================================= + +export async function runInitCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const projectName = parsed.args[0] || 'my-devflare-app' + const templateName = (parsed.options.template as string) || 'minimal' + const cwd = options.cwd || process.cwd() + const theme = createCliTheme(parsed.options) + + logLine(logger) + logLine(logger, `${cyanBold('init', theme)} ${dim('Creating a new Devflare project', theme)}`) + logLine(logger, `${dim('project', theme)} ${green(projectName, theme)}`) + + // Validate template + const template = TEMPLATES[templateName] + if (!template) { + logger.error(`Unknown template: ${templateName}`) + logger.info(`Available templates: ${Object.keys(TEMPLATES).join(', ')}`) + return { exitCode: 1 } + } + + const projectDir = resolve(cwd, projectName) + const dependencyVersions = await getInitDependencyVersions() + + // Get filesystem dependency + const { fs } = await getDependencies() + + try { + await fs.access(projectDir) + logger.error(`Directory already exists: ${projectDir}`) + return { exitCode: 1 } + } catch { + // Directory doesn't exist, good to proceed + } + + // Create project directory + await fs.mkdir(projectDir, { recursive: true }) + + // Create files from template + for (const [filePath, content] of Object.entries(template.files)) { + const fullPath = join(projectDir, filePath) + const dir = fullPath.substring(0, fullPath.lastIndexOf('/')) + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }).catch(() => { }) + + // Replace placeholders + const processedContent = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName) + .replace(/\{\{DEVFLARE_VERSION\}\}/g, dependencyVersions.devflare) + .replace(/\{\{TYPESCRIPT_VERSION\}\}/g, dependencyVersions.typescript) + .replace(/\{\{WRANGLER_VERSION\}\}/g, dependencyVersions.wrangler) + .replace(/\{\{WORKERS_TYPES_VERSION\}\}/g, dependencyVersions.workersTypes) + + await fs.writeFile(fullPath, processedContent, 'utf-8') + logLine(logger, ` ${dim('created', theme)} ${filePath}`) + } + + logger.success('Project created successfully!') + logLine(logger) + logLine(logger, dim('next steps', theme)) + logLine(logger, ` cd ${projectName}`) + logLine(logger, ' bun install') + logLine(logger, ' bun run types') + logLine(logger, ' bun run dev') + + return { exitCode: 0 } +} diff --git a/packages/devflare/src/cli/commands/login.ts b/packages/devflare/src/cli/commands/login.ts new file mode 100644 index 0000000..69862fe --- /dev/null +++ b/packages/devflare/src/cli/commands/login.ts @@ -0,0 +1,70 @@ +import type { ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { account } from '../../cloudflare' +import { getConfiguredAccountId } from '../command-utils' +import { getDependencies } from '../dependencies' +import { createCliTheme, dim, green, logLine, yellow, whiteDim } from '../ui' + +async function logResolvedAccount(cwd: string, logger: ConsolaInstance, theme: ReturnType): Promise { + try { + const primaryAccount = await account.getPrimaryAccount() + if (primaryAccount) { + logLine(logger, `${dim('Primary account:', theme)} ${green(primaryAccount.name, theme)} ${whiteDim(`(${primaryAccount.id})`, theme)}`) + return + } + } catch { + // Fall back to a configured account hint when the current credentials + // cannot enumerate all accounts. + } + + const configuredAccountId = await getConfiguredAccountId(cwd) + if (!configuredAccountId) { + logLine(logger, dim('Run `devflare account` to inspect available accounts.', theme)) + return + } + + const configuredAccount = await account.getAccountById(configuredAccountId) + if (configuredAccount) { + logLine(logger, `${dim('Configured account:', theme)} ${green(configuredAccount.name, theme)} ${whiteDim(`(${configuredAccount.id})`, theme)}`) + return + } + + logLine(logger, `${dim('Configured account ID:', theme)} ${whiteDim(configuredAccountId, theme)}`) + logLine(logger, dim('Run `devflare account --account ` to inspect the configured account.', theme)) +} + +export async function runLoginCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const force = parsed.options.force === true + const cwd = options.cwd ?? process.cwd() + const theme = createCliTheme(parsed.options) + + if (!force && await account.isAuthenticated()) { + logger.success('Already authenticated with Cloudflare') + await logResolvedAccount(cwd, logger, theme) + + logLine(logger, dim('Use `devflare login --force` to open Wrangler login again.', theme)) + return { exitCode: 0 } + } + + logLine(logger) + logLine(logger, `${yellow('login', theme)} ${dim('Opening Wrangler loginโ€ฆ', theme)}`) + const deps = await getDependencies() + const result = await deps.exec.exec('bunx', ['wrangler', 'login'], { + cwd, + stdio: 'inherit' as any + }) + + if (result.exitCode !== 0) { + logger.error('Wrangler login failed') + return { exitCode: 1 } + } + + logger.success('Authenticated with Cloudflare') + await logResolvedAccount(cwd, logger, theme) + + return { exitCode: 0 } +} diff --git a/packages/devflare/src/cli/commands/previews-support/cleanup.ts b/packages/devflare/src/cli/commands/previews-support/cleanup.ts new file mode 100644 index 0000000..7e7e793 --- /dev/null +++ b/packages/devflare/src/cli/commands/previews-support/cleanup.ts @@ -0,0 +1,158 @@ +import type { ConsolaInstance } from 'consola' +import { dim, green, logLine } from './theme' +import type { + PreviewCleanupExecution, + PreviewCleanupTarget, + PreviewOutputTheme, + PreviewScopeSelection +} from './types' + +function comparePreviewCleanupScopeNames(left: string, right: string): number { + if (left === 'preview' && right !== 'preview') { + return 1 + } + + if (left !== 'preview' && right === 'preview') { + return -1 + } + + return left.localeCompare(right) +} + +export function buildPreviewCleanupTarget( + scope: string, + workerCandidatesByScope: Map, + environment: string | undefined +): PreviewCleanupTarget { + const strategies = new Set() + const workerNames = [...(workerCandidatesByScope.get(scope) ?? [])] + + if (workerNames.length > 0) { + strategies.add('dedicated workers') + } + + if (scope === 'preview' && environment === 'preview') { + strategies.add('default preview scope') + } + + return { + scope, + strategies: Array.from(strategies), + workerNames + } +} + +export function buildPreviewCleanupTargets( + workerCandidatesByScope: Map, + environment: string | undefined +): PreviewCleanupTarget[] { + const scopeNames = new Set() + + for (const scope of workerCandidatesByScope.keys()) { + scopeNames.add(scope) + } + + return Array.from(scopeNames) + .sort(comparePreviewCleanupScopeNames) + .map((scope) => buildPreviewCleanupTarget(scope, workerCandidatesByScope, environment)) +} + +export function getPreviewCleanupResourceCandidateCount( + result: PreviewCleanupExecution['result'] +): number { + return result.candidates.kv.length + + result.candidates.d1.length + + result.candidates.r2.length + + result.candidates.queues.length + + result.candidates.vectorize.length + + result.candidates.hyperdrive.length +} + +function buildPreviewCleanupResourceSummary( + result: PreviewCleanupExecution['result'] +): string[] { + return [ + result.candidates.kv.length > 0 ? `KV ${result.candidates.kv.length}` : null, + result.candidates.d1.length > 0 ? `D1 ${result.candidates.d1.length}` : null, + result.candidates.r2.length > 0 ? `R2 ${result.candidates.r2.length}` : null, + result.candidates.queues.length > 0 ? `Queues ${result.candidates.queues.length}` : null, + result.candidates.vectorize.length > 0 ? `Vectorize ${result.candidates.vectorize.length}` : null, + result.candidates.hyperdrive.length > 0 ? `Hyperdrive ${result.candidates.hyperdrive.length}` : null + ].filter((segment): segment is string => segment !== null) +} + +export function logResolvedPreviewScopes( + logger: ConsolaInstance, + targets: PreviewCleanupTarget[], + theme: PreviewOutputTheme +): void { + if (targets.length === 0) { + logLine(logger, `${dim('preview scopes', theme)} ${dim('none discovered (--all)', theme)}`) + logLine(logger) + return + } + + logLine( + logger, + `${dim('preview scopes', theme)} ${green(targets.map((target) => target.scope).join(', '), theme)} ${dim('(--all)', theme)}` + ) + logLine(logger) +} + +export function logPreviewCleanupScopeBreakdown( + logger: ConsolaInstance, + executions: PreviewCleanupExecution[], + theme: PreviewOutputTheme +): void { + const scopedExecutions = executions.filter((execution) => execution.target) + if (scopedExecutions.length === 0) { + return + } + + logLine(logger, `${dim('scope breakdown', theme)}`) + for (const execution of scopedExecutions) { + const target = execution.target! + const strategies = target.strategies.length > 0 + ? dim(`(${target.strategies.join(' + ')})`, theme) + : '' + const summary = [ + target.workerNames.length > 0 ? `Workers ${target.workerNames.length}` : null, + ...buildPreviewCleanupResourceSummary(execution.result) + ].filter((segment): segment is string => segment !== null) + + logLine( + logger, + ` ${green(target.scope, theme)} ${strategies} ${dim('โ€”', theme)} ${summary.length > 0 ? summary.join(' ยท ') : dim('none', theme)}` + ) + } + logLine(logger) +} + +export function showNoPreviewCleanupCandidatesHint( + logger: ConsolaInstance, + selection: PreviewScopeSelection | undefined, + includeAll: boolean, + theme: PreviewOutputTheme +): void { + if (includeAll) { + logger.warn( + 'No preview-only resources or dedicated preview Worker scripts were discovered across the live preview scopes Devflare could resolve. This usually means those previews were already cleaned up or the remaining previews only share stable Workers and shared account resources.' + ) + return + } + + if (!selection?.identifier) { + return + } + + if (selection.source === 'environment') { + logger.warn( + `No preview-only resources or dedicated preview Worker scripts matched the default "${selection.identifier}" scope. If your previews use branch-style scopes such as "next" or "pr-1", rerun with --scope , use --all, or set DEVFLARE_PREVIEW_BRANCH, DEVFLARE_PREVIEW_PR, or DEVFLARE_PREVIEW_IDENTIFIER.` + ) + return + } + + logger.warn( + `No preview-only resources or dedicated preview Worker scripts matched the resolved "${selection.identifier}" scope. This usually means that scope was already cleaned up or the preview shares stable Workers without preview.scope() resources of its own.` + ) +} diff --git a/packages/devflare/src/cli/commands/previews-support/family.ts b/packages/devflare/src/cli/commands/previews-support/family.ts new file mode 100644 index 0000000..d426f90 --- /dev/null +++ b/packages/devflare/src/cli/commands/previews-support/family.ts @@ -0,0 +1,254 @@ +import type { WorkerInfo } from '../../../cloudflare' +import { + resolveConfigForEnvironment, + type DevflareConfig +} from '../../../config' +import type { + ConfiguredWorkerFamilyMember, + PreviewScopeRow, + StableWorkerRow +} from './types' + +function compareConfiguredWorkerFamilies( + left: ConfiguredWorkerFamilyMember, + right: ConfiguredWorkerFamilyMember +): number { + if (left.role === 'primary' && right.role !== 'primary') { + return -1 + } + + if (left.role !== 'primary' && right.role === 'primary') { + return 1 + } + + return left.baseName.localeCompare(right.baseName) +} + +function comparePreviewScopeRows(left: PreviewScopeRow, right: PreviewScopeRow): number { + const leftTime = left.updatedAt?.getTime() ?? 0 + const rightTime = right.updatedAt?.getTime() ?? 0 + if (rightTime !== leftTime) { + return rightTime - leftTime + } + + return left.scope.localeCompare(right.scope) +} + +export function collectConfiguredWorkerFamilies( + config: DevflareConfig, + environment: string | undefined +): ConfiguredWorkerFamilyMember[] { + const resolvedConfig = resolveConfigForEnvironment(config, environment) + const families = new Map() + + families.set(resolvedConfig.name, { + baseName: resolvedConfig.name, + roleLabel: 'primary', + role: 'primary' + }) + + for (const [bindingName, binding] of Object.entries(resolvedConfig.bindings?.services ?? {})) { + const existing = families.get(binding.service) + if (existing) { + continue + } + + families.set(binding.service, { + baseName: binding.service, + roleLabel: bindingName, + role: 'service' + }) + } + + return Array.from(families.values()).sort(compareConfiguredWorkerFamilies) +} + +function getWorkerUrl(workerName: string, workersSubdomain: string | null | undefined): string | undefined { + if (!workersSubdomain) { + return undefined + } + + return `https://${workerName}.${workersSubdomain}.workers.dev` +} + +export function getWorkerScopeSuffix(workerName: string, baseName: string): string | undefined { + if (!workerName.startsWith(`${baseName}-`)) { + return undefined + } + + const suffix = workerName.slice(baseName.length + 1).trim() + return suffix || undefined +} + +export function buildStableWorkerRowsFromLiveWorkers( + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[], + workersSubdomain: string | null | undefined +): StableWorkerRow[] { + const workersByName = new Map(workers.map((worker) => [worker.name, worker])) + + return families.map((family) => { + const worker = workersByName.get(family.baseName) + const status: StableWorkerRow['status'] = worker ? 'active' : 'missing' + + return { + workerName: family.baseName, + role: family.roleLabel, + status, + updatedAt: worker?.modifiedOn, + url: worker ? getWorkerUrl(family.baseName, workersSubdomain) : undefined + } + }) +} + +function getDedicatedPreviewFamilyNamesFromWorkers( + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[] +): Set { + const familyNames = new Set() + const workerNames = workers.map((worker) => worker.name) + + for (const family of families) { + if (family.role === 'primary') { + familyNames.add(family.baseName) + continue + } + + if (workerNames.some((workerName) => Boolean(getWorkerScopeSuffix(workerName, family.baseName)))) { + familyNames.add(family.baseName) + } + } + + return familyNames +} + +export function buildPreviewScopeRowsFromLiveWorkers( + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[], + workersSubdomain: string | null | undefined +): PreviewScopeRow[] { + const workersByName = new Map(workers.map((worker) => [worker.name, worker])) + const previewFamilyNames = getDedicatedPreviewFamilyNamesFromWorkers(families, workers) + const expectedFamilies = families.filter((family) => previewFamilyNames.has(family.baseName)) + const workerCandidatesByScope = buildPreviewWorkerCandidatesByScope(families, workers) + + return Array.from(workerCandidatesByScope.keys()).map((scope) => { + const resolvedFamilies = expectedFamilies.map((family) => ({ + family, + worker: workersByName.get(`${family.baseName}-${scope}`) + })) + const presentFamilies = resolvedFamilies.filter((entry) => entry.worker) + const updatedAt = presentFamilies.reduce((latest, entry) => { + const currentDate = entry.worker?.modifiedOn + if (!currentDate) { + return latest + } + + if (!latest || currentDate.getTime() > latest.getTime()) { + return currentDate + } + + return latest + }, undefined) + const primaryEntry = resolvedFamilies.find((entry) => entry.family.role === 'primary') + const entryWorker = primaryEntry?.worker ?? presentFamilies[0]?.worker + const missingLabels = resolvedFamilies + .filter((entry) => !entry.worker) + .map((entry) => entry.family.role === 'primary' ? 'primary' : entry.family.roleLabel) + const notes: string[] = [] + + if (missingLabels.length > 0) { + notes.push(`missing ${missingLabels.join(', ')}`) + } + const strategy: PreviewScopeRow['strategy'] = 'dedicated workers' + const status: PreviewScopeRow['status'] = presentFamilies.length === resolvedFamilies.length ? 'ready' : 'partial' + + return { + scope, + strategy, + workersLabel: `${presentFamilies.length}/${resolvedFamilies.length}`, + status, + updatedAt, + notes: notes.length > 0 ? notes.join(' ยท ') : undefined, + entryUrl: entryWorker ? getWorkerUrl(entryWorker.name, workersSubdomain) : undefined + } + }).sort(comparePreviewScopeRows) +} + +export function buildPreviewWorkerCandidatesByScope( + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[] +): Map { + const candidates = new Map>() + + for (const worker of workers) { + for (const family of families) { + const scope = getWorkerScopeSuffix(worker.name, family.baseName) + if (!scope) { + continue + } + + const names = candidates.get(scope) ?? new Set() + names.add(worker.name) + candidates.set(scope, names) + } + } + + return new Map(Array.from(candidates.entries()).map(([scope, workerNames]) => { + return [scope, Array.from(workerNames).sort((left, right) => left.localeCompare(right))] + })) +} + +export function orderPreviewWorkerNamesForDeletion( + workerNames: string[], + scope: string, + families: ConfiguredWorkerFamilyMember[] +): string[] { + const familyPriority = new Map() + + for (const family of families) { + familyPriority.set(family.baseName, { + priority: family.role === 'primary' ? 0 : 1, + roleLabel: family.roleLabel + }) + } + + const resolveFamilyForWorker = (workerName: string): { priority: number; roleLabel: string; baseName?: string } => { + for (const family of families) { + if (getWorkerScopeSuffix(workerName, family.baseName) === scope) { + const resolved = familyPriority.get(family.baseName) + if (resolved) { + return { + priority: resolved.priority, + roleLabel: resolved.roleLabel, + baseName: family.baseName + } + } + } + } + + return { + priority: 2, + roleLabel: workerName + } + } + + return [...workerNames].sort((left, right) => { + const leftFamily = resolveFamilyForWorker(left) + const rightFamily = resolveFamilyForWorker(right) + + if (leftFamily.priority !== rightFamily.priority) { + return leftFamily.priority - rightFamily.priority + } + + if (leftFamily.roleLabel !== rightFamily.roleLabel) { + return leftFamily.roleLabel.localeCompare(rightFamily.roleLabel) + } + + if (leftFamily.baseName && rightFamily.baseName && leftFamily.baseName !== rightFamily.baseName) { + return leftFamily.baseName.localeCompare(rightFamily.baseName) + } + + return left.localeCompare(right) + }) +} diff --git a/packages/devflare/src/cli/commands/previews-support/render.ts b/packages/devflare/src/cli/commands/previews-support/render.ts new file mode 100644 index 0000000..bc1dd0c --- /dev/null +++ b/packages/devflare/src/cli/commands/previews-support/render.ts @@ -0,0 +1,271 @@ +import type { ConsolaInstance } from 'consola' +import type { WorkerInfo } from '../../../cloudflare' +import { inspectBindingAssociations, type BindingAssociationRow } from '../../preview-bindings' +import { + buildPreviewScopeRowsFromLiveWorkers, + buildStableWorkerRowsFromLiveWorkers, +} from './family' +import { + bold, + cyanBold, + dim, + formatOverviewStatus, + formatRecordDate, + formatTableLine, + green, + logLine, + whiteDim, + yellowBold +} from './theme' +import type { + ConfiguredWorkerFamilyMember, + PreviewOutputTheme, + PreviewScopeRow, + StableWorkerRow, + TableColumn +} from './types' + +function logWorkerFamilyHeader( + logger: ConsolaInstance, + families: ConfiguredWorkerFamilyMember[], + theme: PreviewOutputTheme +): void { + const primaryFamily = families.find((family) => family.role === 'primary') ?? families[0] + const relatedFamilies = families.filter((family) => family.role !== 'primary') + + logLine(logger, `${dim('worker family', theme)} ${green(primaryFamily?.baseName ?? 'unknown', theme)}`) + if (relatedFamilies.length > 0) { + logLine(logger, `${dim('related workers', theme)} ${whiteDim(String(relatedFamilies.length), theme)}`) + } + logLine(logger) +} + +function logSection( + logger: ConsolaInstance, + title: string, + records: Row[], + columns: TableColumn[], + theme: PreviewOutputTheme +): void { + for (const line of buildSectionLines(title, records, columns, theme)) { + logLine(logger, line) + } +} + +function logLiveWorkerFamilyOverview( + logger: ConsolaInstance, + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[], + workersSubdomain: string | null | undefined, + theme: PreviewOutputTheme +): void { + const stableRows = buildStableWorkerRowsFromLiveWorkers(families, workers, workersSubdomain) + const previewScopeRows = buildPreviewScopeRowsFromLiveWorkers(families, workers, workersSubdomain) + + logWorkerFamilyHeader(logger, families, theme) + logSection(logger, 'Stable workers', stableRows, buildStableWorkerColumns(theme), theme) + + logLine(logger) + if (previewScopeRows.length === 0) { + logLine(logger, dim('No dedicated preview scopes found for this worker family.', theme)) + } else { + logSection(logger, 'Preview scopes', previewScopeRows, buildPreviewScopeColumns(theme), theme) + } +} + +function buildStableWorkerColumns(theme: PreviewOutputTheme): TableColumn[] { + return [ + { + label: 'Worker', + width: 34, + value: (row) => row.workerName + }, + { + label: 'Role', + width: 20, + value: (row) => row.role + }, + { + label: 'Status', + width: 8, + value: (row) => formatOverviewStatus(row.status, theme) + }, + { + label: 'Updated', + width: 19, + value: (row) => whiteDim(formatRecordDate(row.updatedAt), theme) + }, + { + label: 'URL', + value: (row) => row.url ?? 'N/A' + } + ] +} + +function buildPreviewScopeColumns(theme: PreviewOutputTheme): TableColumn[] { + return [ + { + label: 'Scope', + width: 18, + value: (row) => row.scope + }, + { + label: 'Strategy', + width: 18, + value: (row) => row.strategy + }, + { + label: 'Workers', + width: 7, + value: (row) => row.workersLabel + }, + { + label: 'Status', + width: 10, + value: (row) => formatOverviewStatus(row.status, theme) + }, + { + label: 'Updated', + width: 19, + value: (row) => whiteDim(formatRecordDate(row.updatedAt), theme) + }, + { + label: 'Notes', + width: 30, + value: (row) => row.notes ?? dim('โ€”', theme) + }, + { + label: 'Entry URL', + value: (row) => row.entryUrl ?? 'N/A' + } + ] +} + +function buildSectionLines( + title: string, + records: Row[], + columns: TableColumn[], + theme: PreviewOutputTheme +): string[] { + if (records.length === 0) { + return [] + } + + const widths = columns.map((column) => column.width) + const coloredTitle = title === 'Preview scopes' + ? cyanBold(title, theme) + : title === 'Stable workers' + ? bold(title, theme) + : yellowBold(title, theme) + return [ + `${coloredTitle} ${dim(`(${records.length})`, theme)}`, + formatTableLine(columns.map((column) => dim(column.label, theme)), widths), + ...records.map((record) => formatTableLine(columns.map((column) => column.value(record)), widths)) + ] +} + +export function showWorkerFamilyOverviewFromLiveWorkers( + families: ConfiguredWorkerFamilyMember[], + workers: WorkerInfo[], + workersSubdomain: string | null | undefined, + logger: ConsolaInstance, + theme: PreviewOutputTheme +): void { + logLine(logger) + logLiveWorkerFamilyOverview(logger, families, workers, workersSubdomain, theme) + logLine(logger) + logLine(logger, dim('Preview scopes are derived from live dedicated preview Worker names and the current config family.', theme)) + logLine(logger, dim('Use `devflare previews cleanup --scope ` to delete one scope or `--all` to clean every discovered scope.', theme)) + logLine(logger) +} + +export function showWorkspaceWorkerFamilyOverviewFromLiveWorkers( + familyGroups: ConfiguredWorkerFamilyMember[][], + workers: WorkerInfo[], + workersSubdomain: string | null | undefined, + logger: ConsolaInstance, + theme: PreviewOutputTheme +): void { + logLine(logger) + logLine(logger, `${dim('configured worker families', theme)} ${whiteDim(String(familyGroups.length), theme)}`) + logLine(logger) + + for (const [index, families] of familyGroups.entries()) { + if (index > 0) { + logLine(logger) + } + + logLiveWorkerFamilyOverview(logger, families, workers, workersSubdomain, theme) + } + + logLine(logger) + logLine(logger, dim('Preview scopes are derived from live dedicated preview Worker names and each discovered config family.', theme)) + logLine(logger, dim('Run inside a configured package or pass `--config ` to narrow the summary or clean one family.', theme)) + logLine(logger) +} + +function buildBindingAssociationColumns(theme: PreviewOutputTheme): TableColumn[] { + return [ + { + label: 'Reference', + width: 24, + value: (row) => row.reference + }, + { + label: 'Type', + width: 24, + value: (row) => row.type + }, + { + label: 'Resource', + width: 36, + value: (row) => row.resource + }, + { + label: 'Workers', + width: 7, + value: (row) => String(row.workerCount) + }, + { + label: 'Notes', + width: 28, + value: (row) => row.notes.length > 0 ? row.notes.join(' ยท ') : dim('โ€”', theme) + }, + { + label: 'Connected workers', + value: (row) => row.connectedWorkers.length > 0 + ? row.connectedWorkers.join(', ') + : dim('โ€”', theme) + } + ] +} + +export function showBindingAssociations( + logger: ConsolaInstance, + inspection: Awaited>, + theme: PreviewOutputTheme +): void { + logLine(logger) + logLine(logger, `${dim('worker family', theme)} ${green(inspection.workerName, theme)}`) + logLine(logger, `${dim('resolved targets', theme)} ${whiteDim(String(inspection.targets), theme)}`) + logLine(logger, `${dim('active deployments scanned', theme)} ${whiteDim(String(inspection.scannedWorkers.length), theme)}`) + + if (inspection.rows.length === 0) { + logLine(logger) + logLine(logger, dim('No binding or resource targets were resolved from the current config.', theme)) + logLine(logger) + return + } + + logLine(logger) + logSection(logger, 'Bindings', inspection.rows, buildBindingAssociationColumns(theme), theme) + + if (inspection.warnings.length > 0) { + logLine(logger) + for (const warning of inspection.warnings) { + logger.warn(warning) + } + } + + logLine(logger) +} diff --git a/packages/devflare/src/cli/commands/previews-support/theme.ts b/packages/devflare/src/cli/commands/previews-support/theme.ts new file mode 100644 index 0000000..edf77fb --- /dev/null +++ b/packages/devflare/src/cli/commands/previews-support/theme.ts @@ -0,0 +1,102 @@ +import { + bold, + cyan, + cyanBold, + createCliTheme, + dim, + formatTableLine, + green, + logLine, + red, + whiteDim, + yellow, + yellowBold +} from '../../ui' +import type { + PreviewOutputTheme, + StableWorkerRow, + PreviewScopeRow +} from './types' + +export { + bold, + cyan, + cyanBold, + dim, + formatTableLine, + green, + logLine, + red, + whiteDim, + yellow, + yellowBold +} + +export function shouldUseColor(options: Record): boolean { + return createCliTheme(options).useColor +} + +export function formatRecordDate(date: Date | undefined): string { + return date ? date.toISOString().slice(0, 19).replace('T', ' ') : 'N/A' +} + +export function formatStatus(value: string, theme: PreviewOutputTheme): string { + switch (value) { + case 'active': + return green(value, theme) + case 'superseded': + case 'reassigned': + case 'orphaned': + return yellow(value, theme) + case 'deleted': + case 'rolled_back': + return red(value, theme) + default: + return value + } +} + +export function formatChannel(value: string, theme: PreviewOutputTheme): string { + switch (value) { + case 'preview': + return cyan(value, theme) + case 'production': + return green(value, theme) + default: + return value + } +} + +export function formatOverviewStatus( + value: StableWorkerRow['status'] | PreviewScopeRow['status'], + theme: PreviewOutputTheme +): string { + switch (value) { + case 'ready': + return green(value, theme) + case 'partial': + return yellow(value, theme) + case 'missing': + return red(value, theme) + default: + return formatStatus(value, theme) + } +} + +function truncateCell(value: string, width: number): string { + if (value.length <= width) { + return value + } + + if (width <= 1) { + return 'โ€ฆ' + } + + return `${value.slice(0, width - 1)}โ€ฆ` +} + +export function shortenVersionId(versionId: string, length: number = 12): string { + return versionId.length <= length + ? versionId + : `${versionId.slice(0, length)}โ€ฆ` +} diff --git a/packages/devflare/src/cli/commands/previews-support/types.ts b/packages/devflare/src/cli/commands/previews-support/types.ts new file mode 100644 index 0000000..100426d --- /dev/null +++ b/packages/devflare/src/cli/commands/previews-support/types.ts @@ -0,0 +1,84 @@ +import type { PreviewIdentifierSource } from '../../../config' +import { cleanupPreviewScopedResources } from '../../../config/preview-resources' + +export const PREVIEW_SUBCOMMANDS = ['list', 'bindings', 'cleanup'] as const + +export type PreviewSubcommand = typeof PREVIEW_SUBCOMMANDS[number] +export type WorkerNameSource = 'option' | 'arg' | 'config' | 'none' +export type PreviewScopeSource = PreviewIdentifierSource | 'scope-option' + +export interface PreviewScopeSelection { + identifier?: string + source: PreviewScopeSource +} + +export interface PreviewConfigSummary { + accountId?: string + name?: string +} + +export interface PreviewCommandContext { + accountId: string + workerName?: string + workerNameSource: WorkerNameSource + config?: PreviewConfigSummary + listDiscovery?: PreviewListDiscovery +} + +export interface PreviewOutputTheme { + useColor: boolean +} + +export interface TableColumn { + label: string + width?: number + value: (row: Row) => string +} + +export interface ConfiguredWorkerFamilyMember { + baseName: string + roleLabel: string + role: 'primary' | 'service' +} + +export interface PreviewConfiguredFamilyGroup { + accountId?: string + configPath?: string + families: ConfiguredWorkerFamilyMember[] +} + +export interface PreviewListDiscovery { + accountIds: string[] + familyGroups: PreviewConfiguredFamilyGroup[] +} + +export interface StableWorkerRow { + workerName: string + role: string + status: 'active' | 'missing' + updatedAt?: Date + url?: string +} + +export interface PreviewScopeRow { + scope: string + strategy: 'dedicated workers' + workersLabel: string + status: 'ready' | 'partial' + updatedAt?: Date + notes?: string + entryUrl?: string +} + +export type PreviewCleanupStrategy = PreviewScopeRow['strategy'] | 'default preview scope' + +export interface PreviewCleanupTarget { + scope: string + strategies: PreviewCleanupStrategy[] + workerNames: string[] +} + +export interface PreviewCleanupExecution { + target?: PreviewCleanupTarget + result: Awaited> +} diff --git a/packages/devflare/src/cli/commands/previews.ts b/packages/devflare/src/cli/commands/previews.ts new file mode 100644 index 0000000..8a85afe --- /dev/null +++ b/packages/devflare/src/cli/commands/previews.ts @@ -0,0 +1,666 @@ +import type { ConsolaInstance } from 'consola' +import { + account, + type APIClientOptions +} from '../../cloudflare' +import { loadResolvedConfig, resolvePreviewIdentifier } from '../../config' +import { cleanupPreviewScopedResources } from '../../config/preview-resources' +import { ConfigNotFoundError, loadConfig, resolveConfigPath } from '../../config/loader' +import { + asOptionalString, + resolveCloudflareAccountId, + resolveNamedSelection +} from '../command-utils' +import { findConfigPathsUnderDirectory } from '../config-path' +import { getDependencies } from '../dependencies' +import type { CliOptions, CliResult, ParsedArgs } from '../index' +import { inspectBindingAssociations } from '../preview-bindings' +import { + buildPreviewCleanupTarget, + buildPreviewCleanupTargets, + getPreviewCleanupResourceCandidateCount, + logPreviewCleanupScopeBreakdown, + logResolvedPreviewScopes, + showNoPreviewCleanupCandidatesHint +} from './previews-support/cleanup' +import { + buildPreviewWorkerCandidatesByScope, + collectConfiguredWorkerFamilies, + orderPreviewWorkerNamesForDeletion +} from './previews-support/family' +import { + showBindingAssociations, + showWorkspaceWorkerFamilyOverviewFromLiveWorkers, + showWorkerFamilyOverviewFromLiveWorkers +} from './previews-support/render' +import { dim, green, logLine, shouldUseColor } from './previews-support/theme' +import { + type ConfiguredWorkerFamilyMember, + PREVIEW_SUBCOMMANDS, + type PreviewCommandContext, + type PreviewConfiguredFamilyGroup, + type PreviewCleanupExecution, + type PreviewConfigSummary, + type PreviewListDiscovery, + type PreviewOutputTheme, + type PreviewScopeSelection, + type PreviewSubcommand, + type WorkerNameSource +} from './previews-support/types' + +const CLI_API_OPTIONS: APIClientOptions = { + timeout: 10000 +} + +function compareConfiguredWorkerFamilies( + left: ConfiguredWorkerFamilyMember, + right: ConfiguredWorkerFamilyMember +): number { + if (left.role === 'primary' && right.role !== 'primary') { + return -1 + } + + if (left.role !== 'primary' && right.role === 'primary') { + return 1 + } + + return left.baseName.localeCompare(right.baseName) +} + +function sortConfiguredWorkerFamilies( + families: ConfiguredWorkerFamilyMember[] +): ConfiguredWorkerFamilyMember[] { + return [...families].sort(compareConfiguredWorkerFamilies) +} + +function resolvePrimaryWorkerFamilyName( + families: ConfiguredWorkerFamilyMember[] +): string | undefined { + return families.find((family) => family.role === 'primary')?.baseName ?? families[0]?.baseName +} + +function shouldReplaceConfiguredWorkerFamily( + existing: ConfiguredWorkerFamilyMember | undefined, + candidate: ConfiguredWorkerFamilyMember +): boolean { + return !existing || (candidate.role === 'primary' && existing.role !== 'primary') +} + +function mergeConfiguredWorkerFamilies( + existing: ConfiguredWorkerFamilyMember[], + candidates: ConfiguredWorkerFamilyMember[] +): ConfiguredWorkerFamilyMember[] { + const merged = new Map(existing.map((family) => [family.baseName, family])) + + for (const candidate of candidates) { + if (shouldReplaceConfiguredWorkerFamily(merged.get(candidate.baseName), candidate)) { + merged.set(candidate.baseName, candidate) + } + } + + return sortConfiguredWorkerFamilies(Array.from(merged.values())) +} + +function comparePreviewConfiguredFamilyGroups( + left: PreviewConfiguredFamilyGroup, + right: PreviewConfiguredFamilyGroup +): number { + const leftName = resolvePrimaryWorkerFamilyName(left.families) ?? left.configPath ?? '' + const rightName = resolvePrimaryWorkerFamilyName(right.families) ?? right.configPath ?? '' + return leftName.localeCompare(rightName) +} + +function upsertPreviewConfiguredFamilyGroup( + groups: Map, + candidate: PreviewConfiguredFamilyGroup +): void { + const primaryFamilyName = resolvePrimaryWorkerFamilyName(candidate.families) + const groupKey = primaryFamilyName ?? candidate.configPath ?? `group-${groups.size}` + const existing = groups.get(groupKey) + + if (!existing) { + groups.set(groupKey, { + ...candidate, + families: sortConfiguredWorkerFamilies(candidate.families) + }) + return + } + + groups.set(groupKey, { + accountId: existing.accountId ?? candidate.accountId, + configPath: existing.configPath ?? candidate.configPath, + families: mergeConfiguredWorkerFamilies(existing.families, candidate.families) + }) +} + +async function discoverPreviewListConfigs( + cwd: string, + configFile: string | undefined, + environment: string | undefined +): Promise { + const groups = new Map() + const accountIds = new Set() + + const loadAndCollect = async (candidateConfigFile?: string): Promise => { + try { + const config = await loadConfig({ cwd, configFile: candidateConfigFile }) + const families = collectConfiguredWorkerFamilies(config, environment) + const accountId = config.accountId?.trim() || undefined + + if (accountId) { + accountIds.add(accountId) + } + + upsertPreviewConfiguredFamilyGroup(groups, { + accountId, + configPath: candidateConfigFile, + families + }) + + return true + } catch (error) { + if (error instanceof ConfigNotFoundError) { + return false + } + + throw error + } + } + + if (configFile) { + await loadAndCollect(configFile) + } else { + const directConfigPath = await resolveConfigPath(cwd) + const loadedDirectly = directConfigPath + ? await loadAndCollect() + : false + if (!loadedDirectly) { + const configPaths = await findConfigPathsUnderDirectory(cwd) + for (const configPath of configPaths) { + await loadAndCollect(configPath) + } + } + } + + return { + accountIds: Array.from(accountIds).sort((left, right) => left.localeCompare(right)), + familyGroups: Array.from(groups.values()).sort(comparePreviewConfiguredFamilyGroups) + } +} + +function isPreviewSubcommand(value: string): value is PreviewSubcommand { + return PREVIEW_SUBCOMMANDS.includes(value as PreviewSubcommand) +} + +function resolvePreviewScopeSelection( + parsed: ParsedArgs, + environment: string | undefined +): PreviewScopeSelection { + const explicitScope = asOptionalString(parsed.options.scope) + || asOptionalString(parsed.options.identifier) + + if (explicitScope) { + return { + identifier: resolvePreviewIdentifier({ identifier: explicitScope }).identifier, + source: 'scope-option' + } + } + + const resolved = resolvePreviewIdentifier({ + environment, + env: process.env + }) + + return { + identifier: resolved.identifier, + source: resolved.source + } +} + +function formatPreviewScopeSource(source: PreviewScopeSelection['source']): string { + switch (source) { + case 'scope-option': + return '--scope' + case 'identifier': + return 'explicit identifier' + case 'env-identifier': + return 'DEVFLARE_PREVIEW_IDENTIFIER' + case 'env-pr': + return 'DEVFLARE_PREVIEW_PR' + case 'env-branch': + return 'DEVFLARE_PREVIEW_BRANCH' + case 'environment': + return 'default preview scope' + default: + return 'no explicit preview scope' + } +} + +function logResolvedPreviewScope( + logger: ConsolaInstance, + selection: PreviewScopeSelection, + theme: PreviewOutputTheme +): void { + if (!selection.identifier) { + return + } + + logLine( + logger, + `${dim('preview scope', theme)} ${green(selection.identifier, theme)} ${dim(`(${formatPreviewScopeSource(selection.source)})`, theme)}` + ) + logLine(logger) +} + +async function loadLocalConfig( + cwd: string, + configFile: string | undefined, + needsConfig: boolean +): Promise { + if (!needsConfig) { + return undefined + } + + if (!configFile) { + const resolvedConfigPath = await resolveConfigPath(cwd) + if (!resolvedConfigPath) { + return undefined + } + + try { + const config = await loadConfig({ + cwd + }) + return { + accountId: config.accountId, + name: config.name + } + } catch (error) { + if (error instanceof ConfigNotFoundError) { + return undefined + } + + throw error + } + } + + try { + const config = await loadConfig({ + cwd, + configFile + }) + return { + accountId: config.accountId, + name: config.name + } + } catch (error) { + if (error instanceof ConfigNotFoundError) { + return undefined + } + + throw error + } +} + +async function resolveAccountId( + parsed: ParsedArgs, + config: PreviewConfigSummary | undefined +): Promise { + return resolveCloudflareAccountId({ + explicitAccountId: asOptionalString(parsed.options.account), + configuredAccountId: config?.accountId, + apiOptions: CLI_API_OPTIONS + }) +} + +function resolveWorkerName( + parsed: ParsedArgs, + config: PreviewConfigSummary | undefined +): { workerName?: string; source: WorkerNameSource } { + const selection = resolveNamedSelection({ + explicitValue: asOptionalString(parsed.options.worker), + configuredValue: config?.name + }) + + return { + workerName: selection.value, + source: selection.source + } +} + +async function resolveContext( + parsed: ParsedArgs, + options: CliOptions, + subcommand: PreviewSubcommand +): Promise { + const cwd = options.cwd ?? process.cwd() + const configFile = asOptionalString(parsed.options.config) + const explicitAccountId = asOptionalString(parsed.options.account) + const environment = asOptionalString(parsed.options.env) + + if (subcommand === 'list') { + const listDiscovery = await discoverPreviewListConfigs(cwd, configFile, environment) + + if (!explicitAccountId && listDiscovery.accountIds.length > 1) { + throw new Error( + 'Multiple Cloudflare account ids were discovered across local Devflare configs. Pass --account to select one account explicitly for `devflare previews`.' + ) + } + + const accountId = await resolveCloudflareAccountId({ + explicitAccountId, + configuredAccountId: listDiscovery.accountIds[0], + apiOptions: CLI_API_OPTIONS + }) + + if (!accountId) { + throw new Error('No Cloudflare account could be resolved. Use --account or configure accountId in devflare.config.*.') + } + + return { + accountId, + workerName: undefined, + workerNameSource: 'none', + config: undefined, + listDiscovery + } + } + + const needsConfig = subcommand === 'cleanup' + || subcommand === 'bindings' + || !explicitAccountId + const config = await loadLocalConfig(cwd, configFile, needsConfig) + + if (needsConfig && !config) { + throw new Error('Preview commands now inspect and clean dedicated preview workers for the current package. Run inside a configured package or pass --config .') + } + + const accountId = await resolveAccountId(parsed, config) + const workerSelection = resolveWorkerName(parsed, config) + + if (!accountId) { + throw new Error('No Cloudflare account could be resolved. Use --account or configure accountId in devflare.config.*.') + } + + return { + accountId, + workerName: workerSelection.workerName, + workerNameSource: workerSelection.source, + config + } +} + +async function runBindingsSubcommand( + parsed: ParsedArgs, + context: PreviewCommandContext, + logger: ConsolaInstance, + options: CliOptions, + environment: string | undefined, + configFile: string | undefined, + theme: PreviewOutputTheme +): Promise { + const cwd = options.cwd ?? process.cwd() + const previewScope = resolvePreviewScopeSelection(parsed, environment) + logResolvedPreviewScope(logger, previewScope, theme) + const resolvedConfig = await loadResolvedConfig({ + cwd, + configFile, + environment, + identifier: previewScope.identifier, + accountId: context.accountId + }) + const deps = await getDependencies() + const inspection = await inspectBindingAssociations({ + accountId: context.accountId, + config: resolvedConfig, + workerName: context.workerName ?? resolvedConfig.name, + cwd, + exec: deps.exec, + apiOptions: CLI_API_OPTIONS + }) + + showBindingAssociations(logger, inspection, theme) + return { exitCode: 0 } +} + +async function runCleanupSubcommand( + parsed: ParsedArgs, + context: PreviewCommandContext, + logger: ConsolaInstance, + options: CliOptions, + environment: string | undefined, + configFile: string | undefined, + includeAll: boolean, + theme: PreviewOutputTheme +): Promise { + const cwd = options.cwd ?? process.cwd() + const resolvedEnvironment = environment ?? 'preview' + const explicitScope = asOptionalString(parsed.options.scope) + || asOptionalString(parsed.options.identifier) + + if (includeAll && explicitScope) { + logger.error('Choose either --scope or --all for preview cleanup, not both.') + return { exitCode: 1 } + } + + const previewScope = resolvePreviewScopeSelection(parsed, resolvedEnvironment) + const config = await loadConfig({ cwd, configFile }) + const configuredFamilies = collectConfiguredWorkerFamilies(config, resolvedEnvironment) + const liveWorkers = await account.workers(context.accountId, CLI_API_OPTIONS) + const workerCandidatesByScope = buildPreviewWorkerCandidatesByScope(configuredFamilies, liveWorkers) + const cleanupTargets = includeAll + ? buildPreviewCleanupTargets(workerCandidatesByScope, resolvedEnvironment) + : previewScope.identifier + ? [buildPreviewCleanupTarget(previewScope.identifier, workerCandidatesByScope, resolvedEnvironment)] + : [] + const cleanupRuns = includeAll + ? cleanupTargets.map((target) => ({ + scope: target.scope, + target + })) + : previewScope.identifier + ? [{ + scope: previewScope.identifier, + target: cleanupTargets[0] + }] + : [] + const applyCleanup = parsed.options.apply === true + const executions: PreviewCleanupExecution[] = [] + + if (includeAll) { + logResolvedPreviewScopes(logger, cleanupTargets, theme) + } else { + logResolvedPreviewScope(logger, previewScope, theme) + } + + for (const cleanupRun of cleanupRuns) { + if (applyCleanup) { + const orderedWorkerNames = cleanupRun.target + ? orderPreviewWorkerNamesForDeletion(cleanupRun.target.workerNames, cleanupRun.target.scope, configuredFamilies) + : [] + + for (const workerName of orderedWorkerNames) { + await account.deleteWorker(context.accountId, workerName, CLI_API_OPTIONS) + } + } + + const result = await cleanupPreviewScopedResources(config, { + environment: resolvedEnvironment, + identifier: cleanupRun.scope, + accountId: context.accountId, + apply: applyCleanup + }) + + executions.push({ + target: cleanupRun.target, + result + }) + } + + const totalWorkerCandidates = executions.reduce((sum, execution) => { + return sum + (execution.target?.workerNames.length ?? 0) + }, 0) + const totalKvCandidates = executions.reduce((sum, execution) => sum + execution.result.candidates.kv.length, 0) + const totalD1Candidates = executions.reduce((sum, execution) => sum + execution.result.candidates.d1.length, 0) + const totalR2Candidates = executions.reduce((sum, execution) => sum + execution.result.candidates.r2.length, 0) + const totalQueueCandidates = executions.reduce((sum, execution) => sum + execution.result.candidates.queues.length, 0) + const totalVectorizeCandidates = executions.reduce((sum, execution) => sum + execution.result.candidates.vectorize.length, 0) + const totalHyperdriveCandidates = executions.reduce((sum, execution) => sum + execution.result.candidates.hyperdrive.length, 0) + const totalResourceCandidates = executions.reduce((sum, execution) => { + return sum + getPreviewCleanupResourceCandidateCount(execution.result) + }, 0) + const totalCandidates = totalWorkerCandidates + totalResourceCandidates + const scopeCountSuffix = cleanupRuns.length > 0 + ? ` across ${cleanupRuns.length} preview scope${cleanupRuns.length === 1 ? '' : 's'}` + : '' + + logger.success( + applyCleanup + ? `Deleted ${totalCandidates} preview-only cleanup candidate${totalCandidates === 1 ? '' : 's'}${scopeCountSuffix}` + : `Preview cleanup dry run complete with ${totalCandidates} candidate${totalCandidates === 1 ? '' : 's'}${scopeCountSuffix}` + ) + + const resourceSummary = [ + totalWorkerCandidates > 0 ? `Workers ${totalWorkerCandidates}` : null, + totalKvCandidates > 0 ? `KV ${totalKvCandidates}` : null, + totalD1Candidates > 0 ? `D1 ${totalD1Candidates}` : null, + totalR2Candidates > 0 ? `R2 ${totalR2Candidates}` : null, + totalQueueCandidates > 0 ? `Queues ${totalQueueCandidates}` : null, + totalVectorizeCandidates > 0 ? `Vectorize ${totalVectorizeCandidates}` : null, + totalHyperdriveCandidates > 0 ? `Hyperdrive ${totalHyperdriveCandidates}` : null + ].filter((segment): segment is string => segment !== null) + + if (resourceSummary.length > 0) { + logger.info(`Candidates: ${resourceSummary.join(' ยท ')}`) + } else { + logger.info('Candidates: none') + showNoPreviewCleanupCandidatesHint(logger, previewScope, includeAll, theme) + } + + if (includeAll || executions.some((execution) => Boolean(execution.target?.workerNames.length))) { + logPreviewCleanupScopeBreakdown(logger, executions, theme) + } + + const warnings = Array.from(new Set(executions.flatMap((execution) => execution.result.warnings))) + for (const warning of warnings) { + logger.warn(warning) + } + + return { exitCode: 0 } +} + +async function runListSubcommand( + context: PreviewCommandContext, + logger: ConsolaInstance, + theme: PreviewOutputTheme +): Promise { + const discoveredFamilyGroups = context.listDiscovery?.familyGroups ?? [] + if (discoveredFamilyGroups.length === 0) { + throw new Error('Preview listing needs a resolvable devflare config in the current package or workspace so Devflare can identify worker families.') + } + + const matchingFamilyGroups = discoveredFamilyGroups.filter((group) => { + return !group.accountId || group.accountId === context.accountId + }) + + if (matchingFamilyGroups.length === 0) { + throw new Error( + `No configured preview worker families matched Cloudflare account ${context.accountId}. Pass --account or --config to narrow the selection.` + ) + } + + const liveWorkers = await account.workers(context.accountId, CLI_API_OPTIONS) + const workersSubdomain = await account.workersSubdomain(context.accountId, CLI_API_OPTIONS) + + if (matchingFamilyGroups.length === 1) { + showWorkerFamilyOverviewFromLiveWorkers( + matchingFamilyGroups[0]!.families, + liveWorkers, + workersSubdomain, + logger, + theme + ) + return { exitCode: 0 } + } + + showWorkspaceWorkerFamilyOverviewFromLiveWorkers( + matchingFamilyGroups.map((group) => group.families), + liveWorkers, + workersSubdomain, + logger, + theme + ) + return { exitCode: 0 } +} + +function resolvePreviewSubcommand(rawSubcommand: string | undefined): PreviewSubcommand | undefined { + if (!rawSubcommand) { + return undefined + } + + if (isPreviewSubcommand(rawSubcommand)) { + return rawSubcommand + } + + return undefined +} + +export async function runPreviewsCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const isAuth = await account.isAuthenticated() + if (!isAuth) { + logger.error('Not authenticated with Cloudflare') + logger.info('Run `devflare login` first.') + return { exitCode: 1 } + } + + const rawSubcommand = parsed.args[0] + const subcommand = resolvePreviewSubcommand(rawSubcommand) ?? 'list' + const includeAll = parsed.options.all === true + const theme: PreviewOutputTheme = { + useColor: shouldUseColor(parsed.options as Record) + } + + if (rawSubcommand && !resolvePreviewSubcommand(rawSubcommand)) { + logger.error(`Unknown previews subcommand: ${rawSubcommand}`) + logger.info(`Available previews subcommands: ${PREVIEW_SUBCOMMANDS.join(', ')}`) + return { exitCode: 1 } + } + + try { + const context = await resolveContext(parsed, options, subcommand) + const environment = asOptionalString(parsed.options.env) + const configFile = asOptionalString(parsed.options.config) + + switch (subcommand) { + case 'bindings': + return runBindingsSubcommand(parsed, context, logger, options, environment, configFile, theme) + + case 'cleanup': + return runCleanupSubcommand( + parsed, + context, + logger, + options, + environment, + configFile, + includeAll, + theme + ) + + case 'list': + default: + return runListSubcommand(context, logger, theme) + } + } catch (error) { + if (error instanceof Error) { + logger.error(error.message) + return { exitCode: 1 } + } + + throw error + } +} diff --git a/packages/devflare/src/cli/commands/productions.ts b/packages/devflare/src/cli/commands/productions.ts new file mode 100644 index 0000000..1e2a3bf --- /dev/null +++ b/packages/devflare/src/cli/commands/productions.ts @@ -0,0 +1,700 @@ +import type { ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { + account, + type APIClientOptions, + type WorkerDeploymentInfo, + type WorkerInfo, + type WorkerVersionInfo +} from '../../cloudflare' +import { loadConfig, ConfigNotFoundError } from '../../config/loader' +import { + asOptionalString, + resolveCloudflareAccountId, + resolveNamedSelection +} from '../command-utils' +import { getDependencies } from '../dependencies' +import { findFiles } from '../../utils/glob' +import { collectConfiguredWorkerFamilies } from './previews-support/family' +import { + bold, + createCliTheme, + cyanBold, + dim, + formatLabelValue, + green, + logLine, + logTable, + red, + whiteDim, + yellow, + type CliTableColumn, + type CliTheme +} from '../ui' +import type { ConfiguredWorkerFamilyMember } from './previews-support/types' + +const CLI_API_OPTIONS: APIClientOptions = { timeout: 10000 } +const PRODUCTION_SUBCOMMANDS = ['list', 'versions', 'rollback', 'delete'] as const +const VERSION_LIST_LIMIT = 10 + +type ProductionSubcommand = typeof PRODUCTION_SUBCOMMANDS[number] +type WorkerNameSource = 'option' | 'arg' | 'config' | 'none' + +interface ProductionDiscoveryResult { + accountId?: string + defaultWorkerName?: string + defaultWorkerNameSource: WorkerNameSource + families: ConfiguredWorkerFamilyMember[] + primaryFamilyNames: string[] +} + +interface ProductionCommandContext { + accountId: string + workerName?: string + workerNameSource: WorkerNameSource + discovery: ProductionDiscoveryResult +} + +interface ProductionWorkerRow { + workerName: string + role: string + status: 'active' | 'missing' | 'undeployed' + deployedAt?: Date + versionId?: string + source?: string + url?: string +} + +interface ProductionVersionRow { + versionId: string + status: 'active' | 'stored' + updatedAt?: Date + deployedAt?: Date + source?: string +} + +interface WorkerVersionOverview { + workerName: string + rows: ProductionVersionRow[] +} + +function isProductionSubcommand(value: string): value is ProductionSubcommand { + return PRODUCTION_SUBCOMMANDS.includes(value as ProductionSubcommand) +} + +function shortenVersionId(versionId: string, length: number = 12): string { + return versionId.length <= length + ? versionId + : `${versionId.slice(0, length)}โ€ฆ` +} + +function selectDeploymentVersionId(deployment: WorkerDeploymentInfo): string | undefined { + return deployment.versions.find((version) => version.percentage === 100)?.versionId + ?? deployment.versions[0]?.versionId +} + +function getWorkerVersionTimestamp(version: WorkerVersionInfo): Date | undefined { + return version.metadata.modifiedOn ?? version.metadata.createdOn +} + +function formatRecordDate(date: Date | undefined): string { + return date ? date.toISOString().slice(0, 19).replace('T', ' ') : 'N/A' +} + +function formatWorkerStatus( + status: ProductionWorkerRow['status'], + theme: CliTheme +): string { + switch (status) { + case 'active': + return green(status, theme) + case 'undeployed': + return yellow(status, theme) + case 'missing': + default: + return red(status, theme) + } +} + +function formatVersionStatus( + status: ProductionVersionRow['status'], + theme: CliTheme +): string { + switch (status) { + case 'active': + return green(status, theme) + case 'stored': + default: + return whiteDim(status, theme) + } +} + +function shouldReplaceConfiguredWorkerFamily( + existing: ConfiguredWorkerFamilyMember | undefined, + candidate: ConfiguredWorkerFamilyMember +): boolean { + return !existing || (candidate.role === 'primary' && existing.role !== 'primary') +} + +function mergeConfiguredWorkerFamily( + families: Map, + candidate: ConfiguredWorkerFamilyMember +): void { + if (shouldReplaceConfiguredWorkerFamily(families.get(candidate.baseName), candidate)) { + families.set(candidate.baseName, candidate) + } +} + +async function discoverProductionConfigs( + cwd: string, + configFile: string | undefined, + environment: string +): Promise { + const families = new Map() + const primaryFamilyNames = new Set() + const accountIds = new Set() + let defaultWorkerName: string | undefined + let defaultWorkerNameSource: WorkerNameSource = 'none' + + const loadAndCollect = async (candidateConfigFile?: string): Promise => { + try { + const config = await loadConfig({ cwd, configFile: candidateConfigFile }) + const resolvedFamilies = collectConfiguredWorkerFamilies(config, environment) + for (const family of resolvedFamilies) { + mergeConfiguredWorkerFamily(families, family) + if (family.role === 'primary') { + primaryFamilyNames.add(family.baseName) + } + } + + if (config.accountId?.trim()) { + accountIds.add(config.accountId.trim()) + } + + if (!defaultWorkerName) { + defaultWorkerName = resolvedFamilies.find((family) => family.role === 'primary')?.baseName + defaultWorkerNameSource = defaultWorkerName ? 'config' : 'none' + } + + return true + } catch (error) { + if (error instanceof ConfigNotFoundError) { + return false + } + + throw error + } + } + + if (configFile) { + await loadAndCollect(configFile) + } else { + const loadedDirectly = await loadAndCollect() + if (!loadedDirectly) { + const configPaths = (await findFiles('**/devflare.config.{ts,js,mjs,cjs}', { + cwd, + absolute: true + })).sort((left, right) => left.localeCompare(right)) + + for (const configPath of configPaths) { + await loadAndCollect(configPath) + } + } + } + + if (accountIds.size > 1) { + throw new Error( + 'Multiple Cloudflare account ids were discovered across local Devflare configs. Pass --account to select one account explicitly for `devflare productions`.' + ) + } + + return { + accountId: Array.from(accountIds)[0], + defaultWorkerName, + defaultWorkerNameSource, + families: Array.from(families.values()).sort((left, right) => { + if (left.role === 'primary' && right.role !== 'primary') { + return -1 + } + + if (left.role !== 'primary' && right.role === 'primary') { + return 1 + } + + return left.baseName.localeCompare(right.baseName) + }), + primaryFamilyNames: Array.from(primaryFamilyNames).sort((left, right) => left.localeCompare(right)) + } +} + +async function resolveAccountId( + parsed: ParsedArgs, + discovery: ProductionDiscoveryResult +): Promise { + return resolveCloudflareAccountId({ + explicitAccountId: asOptionalString(parsed.options.account), + configuredAccountId: discovery.accountId, + apiOptions: CLI_API_OPTIONS + }) +} + +function resolveWorkerName( + parsed: ParsedArgs, + discovery: ProductionDiscoveryResult, + fallbackArg: string | undefined +): { workerName?: string; source: WorkerNameSource } { + const selection = resolveNamedSelection({ + explicitValue: asOptionalString(parsed.options.worker), + fallbackValue: fallbackArg, + configuredValue: discovery.defaultWorkerName + }) + + return { + workerName: selection.value, + source: selection.value === discovery.defaultWorkerName + ? discovery.defaultWorkerNameSource + : selection.source + } +} + +async function resolveContext( + parsed: ParsedArgs, + options: CliOptions, + subcommand: ProductionSubcommand, + fallbackArg: string | undefined +): Promise { + const cwd = options.cwd ?? process.cwd() + const configFile = asOptionalString(parsed.options.config) + const environment = asOptionalString(parsed.options.env) ?? 'production' + const explicitAccountId = asOptionalString(parsed.options.account) + const explicitWorkerName = asOptionalString(parsed.options.worker) ?? fallbackArg + const shouldDiscoverConfigs = Boolean(configFile) || !explicitAccountId || !explicitWorkerName + const discovery = shouldDiscoverConfigs + ? await discoverProductionConfigs(cwd, configFile, environment) + : { + accountId: undefined, + defaultWorkerName: undefined, + defaultWorkerNameSource: 'none' as const, + families: [], + primaryFamilyNames: [] + } + const accountId = await resolveAccountId(parsed, discovery) + const workerSelection = resolveWorkerName(parsed, discovery, fallbackArg) + + if (!accountId) { + throw new Error('No Cloudflare account could be resolved. Use --account or configure accountId in devflare.config.*.') + } + + if ((subcommand === 'rollback' || subcommand === 'delete') && !workerSelection.workerName) { + throw new Error(`A worker name is required for productions ${subcommand}. Use --worker or run inside a configured package with a single primary worker.`) + } + + return { + accountId, + workerName: workerSelection.workerName, + workerNameSource: workerSelection.source, + discovery + } +} + +function getProductionUrl(workerName: string, workersSubdomain: string | null): string | undefined { + if (!workersSubdomain) { + return undefined + } + + const normalizedSubdomain = workersSubdomain + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\.workers\.dev\/?$/i, '') + + return `https://${workerName}.${normalizedSubdomain}.workers.dev` +} + +async function buildProductionRows( + accountId: string, + families: ConfiguredWorkerFamilyMember[], + apiOptions: APIClientOptions +): Promise { + const [liveWorkers, workersSubdomain] = await Promise.all([ + account.workers(accountId, apiOptions), + account.workersSubdomain(accountId, apiOptions) + ]) + const workersByName = new Map(liveWorkers.map((worker) => [worker.name, worker])) + + return Promise.all(families.map(async (family) => { + const worker = workersByName.get(family.baseName) + if (!worker) { + return { + workerName: family.baseName, + role: family.roleLabel, + status: 'missing' as const, + url: getProductionUrl(family.baseName, workersSubdomain) + } + } + + const deployments = await account.workerDeployments(accountId, family.baseName, apiOptions) + const latestDeployment = [...deployments].sort((left, right) => { + return right.createdOn.getTime() - left.createdOn.getTime() + })[0] + const activeVersionId = latestDeployment ? selectDeploymentVersionId(latestDeployment) : undefined + + return { + workerName: family.baseName, + role: family.roleLabel, + status: latestDeployment ? 'active' : 'undeployed', + deployedAt: latestDeployment?.createdOn ?? worker.modifiedOn, + versionId: activeVersionId, + source: latestDeployment?.source, + url: getProductionUrl(family.baseName, workersSubdomain) + } + })) +} + +function buildProductionColumns(theme: CliTheme): CliTableColumn[] { + return [ + { + label: 'Worker', + width: 34, + value: (row) => row.workerName + }, + { + label: 'Role', + width: 18, + value: (row) => row.role + }, + { + label: 'Status', + width: 10, + value: (row) => formatWorkerStatus(row.status, theme) + }, + { + label: 'Deployed', + width: 19, + value: (row) => whiteDim(formatRecordDate(row.deployedAt), theme) + }, + { + label: 'Version', + width: 13, + value: (row) => row.versionId ? shortenVersionId(row.versionId) : dim('N/A', theme) + }, + { + label: 'Source', + width: 14, + value: (row) => row.source ?? dim('N/A', theme) + }, + { + label: 'URL', + value: (row) => row.url ?? 'N/A' + } + ] +} + +function buildVersionColumns(theme: CliTheme): CliTableColumn[] { + return [ + { + label: 'Version', + width: 13, + value: (row) => shortenVersionId(row.versionId) + }, + { + label: 'Status', + width: 8, + value: (row) => formatVersionStatus(row.status, theme) + }, + { + label: 'Updated', + width: 19, + value: (row) => whiteDim(formatRecordDate(row.updatedAt), theme) + }, + { + label: 'Last deployed', + width: 19, + value: (row) => whiteDim(formatRecordDate(row.deployedAt), theme) + }, + { + label: 'Source', + value: (row) => row.source ?? dim('N/A', theme) + } + ] +} + +async function loadWorkerVersionOverview( + accountId: string, + workerName: string, + apiOptions: APIClientOptions +): Promise { + const [versions, deployments] = await Promise.all([ + account.workerVersions(accountId, workerName, apiOptions), + account.workerDeployments(accountId, workerName, apiOptions) + ]) + const productionVersions = versions + .filter((version) => version.id) + .filter((version) => version.metadata.hasPreview !== true) + .sort((left, right) => { + const leftTime = getWorkerVersionTimestamp(left)?.getTime() ?? 0 + const rightTime = getWorkerVersionTimestamp(right)?.getTime() ?? 0 + return rightTime - leftTime + }) + .slice(0, VERSION_LIST_LIMIT) + const activeVersionIds = new Set((deployments[0]?.versions ?? []).map((version) => version.versionId)) + const latestDeploymentByVersionId = new Map() + + for (const deployment of deployments) { + for (const version of deployment.versions) { + const existing = latestDeploymentByVersionId.get(version.versionId) + if (!existing || deployment.createdOn.getTime() > existing.getTime()) { + latestDeploymentByVersionId.set(version.versionId, deployment.createdOn) + } + } + } + + return { + workerName, + rows: productionVersions.map((version) => ({ + versionId: version.id, + status: activeVersionIds.has(version.id) ? 'active' : 'stored', + updatedAt: getWorkerVersionTimestamp(version), + deployedAt: latestDeploymentByVersionId.get(version.id), + source: version.metadata.source + })) + } +} + +function showProductionOverview( + logger: ConsolaInstance, + context: ProductionCommandContext, + rows: ProductionWorkerRow[], + theme: CliTheme +): void { + logLine(logger) + + if (context.discovery.primaryFamilyNames.length === 1) { + logLine(logger, formatLabelValue('worker family', green(context.discovery.primaryFamilyNames[0], theme), theme)) + logLine(logger, formatLabelValue('related', whiteDim(String(Math.max(context.discovery.families.length - 1, 0)), theme), theme)) + } else if (context.discovery.primaryFamilyNames.length > 1) { + logLine(logger, formatLabelValue('configured', whiteDim(`${context.discovery.primaryFamilyNames.length} primary workers`, theme), theme)) + logLine(logger, formatLabelValue('tracked', whiteDim(`${context.discovery.families.length} workers`, theme), theme)) + } else if (context.workerName) { + logLine(logger, formatLabelValue('worker', green(context.workerName, theme), theme)) + } + + if (rows.length === 0) { + logLine(logger) + logLine(logger, dim('No production Workers matched the current selection.', theme)) + logLine(logger) + return + } + + logLine(logger) + logTable(logger, { + title: 'Productions', + rows, + columns: buildProductionColumns(theme), + theme, + titleAccent: 'green' + }) + logLine(logger) + logLine(logger, dim('Use `devflare productions versions` for recent production versions, or `rollback` / `delete` to mutate one Worker.', theme)) + logLine(logger) +} + +function showWorkerVersions( + logger: ConsolaInstance, + overviews: WorkerVersionOverview[], + theme: CliTheme +): void { + logLine(logger) + + if (overviews.length === 0) { + logLine(logger, dim('No production versions were found for the current selection.', theme)) + logLine(logger) + return + } + + for (const [index, overview] of overviews.entries()) { + if (index > 0) { + logLine(logger) + } + + logLine(logger, `${bold('worker', theme)} ${green(overview.workerName, theme)}`) + if (overview.rows.length === 0) { + logLine(logger, dim('No stored production versions were found for this Worker.', theme)) + continue + } + + logTable(logger, { + title: 'Versions', + rows: overview.rows, + columns: buildVersionColumns(theme), + theme, + titleAccent: 'cyan' + }) + } + + logLine(logger) +} + +async function runRollback( + context: ProductionCommandContext, + parsed: ParsedArgs, + options: CliOptions, + logger: ConsolaInstance, + theme: CliTheme +): Promise { + if (!context.workerName) { + logger.error('A worker name is required for production rollback.') + return { exitCode: 1 } + } + + const versionId = asOptionalString(parsed.options.version) + || asOptionalString(parsed.options['version-id']) + const apply = parsed.options.apply === true + + if (!apply) { + logger.success(`Production rollback dry run complete for ${context.workerName}`) + logger.info( + versionId + ? `Would roll back ${context.workerName} to version ${versionId}` + : `Would roll back ${context.workerName} to the previously deployed production version` + ) + return { exitCode: 0 } + } + + const rollbackMessage = asOptionalString(parsed.options.message) + ?? `Rolled back ${context.workerName} via devflare productions rollback` + const rollbackArgs = ['wrangler', 'rollback'] + if (versionId) { + rollbackArgs.push(versionId) + } + rollbackArgs.push('--name', context.workerName, '--message', rollbackMessage) + + logLine(logger) + logLine(logger, `${cyanBold('productions rollback', theme)} ${dim(`Rolling back ${context.workerName}`, theme)}`) + + const deps = await getDependencies() + const cwd = options.cwd ?? process.cwd() + const rollbackResult = await deps.exec.exec('bunx', rollbackArgs, { + cwd, + stdio: 'inherit' + }) + + if (rollbackResult.exitCode !== 0) { + logger.error(`Rollback failed for ${context.workerName}`) + return { exitCode: 1 } + } + + const deployments = await account.workerDeployments(context.accountId, context.workerName, CLI_API_OPTIONS) + const activeVersionId = deployments[0] ? selectDeploymentVersionId(deployments[0]) : undefined + + logger.success(`Rolled back production deployment for ${context.workerName}`) + if (activeVersionId) { + logger.info(`Active version: ${activeVersionId}`) + } + + return { exitCode: 0 } +} + +async function runDelete( + context: ProductionCommandContext, + parsed: ParsedArgs, + logger: ConsolaInstance +): Promise { + if (!context.workerName) { + logger.error('A worker name is required for production deletion.') + return { exitCode: 1 } + } + + const apply = parsed.options.apply === true + if (!apply) { + logger.success(`Production delete dry run complete for ${context.workerName}`) + logger.info(`Would delete Worker script ${context.workerName}`) + logger.warn('Deleting a production Worker script does not automatically delete KV, D1, R2, queue, or other account resources.') + return { exitCode: 0 } + } + + await account.deleteWorker(context.accountId, context.workerName, CLI_API_OPTIONS) + logger.success(`Deleted production Worker script ${context.workerName}`) + logger.warn('Devflare deleted the Worker script only. Review any shared account resources separately before cleaning them up.') + return { exitCode: 0 } +} + +export async function runProductionsCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const isAuth = await account.isAuthenticated() + if (!isAuth) { + logger.error('Not authenticated with Cloudflare') + logger.info('Run `devflare login` first.') + return { exitCode: 1 } + } + + const rawSubcommand = parsed.args[0] + const fallbackWorkerArg = rawSubcommand && !isProductionSubcommand(rawSubcommand) + ? rawSubcommand + : parsed.args[1] + const subcommand: ProductionSubcommand = rawSubcommand && isProductionSubcommand(rawSubcommand) + ? rawSubcommand + : 'list' + const theme = createCliTheme(parsed.options) + + if (rawSubcommand && !isProductionSubcommand(rawSubcommand) && parsed.args.length > 2) { + logger.error(`Unknown productions subcommand: ${rawSubcommand}`) + logger.info(`Available productions subcommands: ${PRODUCTION_SUBCOMMANDS.join(', ')}`) + return { exitCode: 1 } + } + + try { + const context = await resolveContext(parsed, options, subcommand, fallbackWorkerArg) + const selectedFamilies = context.discovery.families.length > 0 + ? context.discovery.families + : context.workerName + ? [{ baseName: context.workerName, roleLabel: 'selected worker', role: 'primary' as const }] + : [] + + switch (subcommand) { + case 'versions': { + const workerNames = Array.from(new Set( + (context.workerName ? [context.workerName] : selectedFamilies.map((family) => family.baseName)) + )).sort((left, right) => left.localeCompare(right)) + + if (workerNames.length === 0) { + logger.error('No production Workers could be resolved. Use --worker or run inside a configured package.') + return { exitCode: 1 } + } + + const overviews = await Promise.all( + workerNames.map((workerName) => loadWorkerVersionOverview(context.accountId, workerName, CLI_API_OPTIONS)) + ) + showWorkerVersions(logger, overviews, theme) + return { exitCode: 0 } + } + + case 'rollback': + return runRollback(context, parsed, options, logger, theme) + + case 'delete': + return runDelete(context, parsed, logger) + + case 'list': + default: { + if (selectedFamilies.length === 0) { + logger.error('No production Workers could be resolved. Use --worker, --config, or run inside a configured package.') + return { exitCode: 1 } + } + + const rows = await buildProductionRows(context.accountId, selectedFamilies, CLI_API_OPTIONS) + showProductionOverview(logger, context, rows, theme) + return { exitCode: 0 } + } + } + } catch (error) { + if (error instanceof Error) { + logger.error(error.message) + return { exitCode: 1 } + } + + throw error + } +} diff --git a/packages/devflare/src/cli/commands/remote.ts b/packages/devflare/src/cli/commands/remote.ts new file mode 100644 index 0000000..f0853ea --- /dev/null +++ b/packages/devflare/src/cli/commands/remote.ts @@ -0,0 +1,125 @@ +// ============================================================================= +// CLI Remote Command +// ============================================================================= +// `devflare remote` โ€” Manage remote test mode +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { BOLD, DIM, RESET, GREEN, RED, YELLOW } from '../colors' +import { + enableRemoteMode, + disableRemoteMode, + getEffectiveRemoteModeStatus +} from '../../cloudflare/remote-config' + +// ----------------------------------------------------------------------------- +// Output Helpers +// ----------------------------------------------------------------------------- + +function log(message: string = ''): void { + console.log(message) +} + +// ----------------------------------------------------------------------------- +// Subcommands +// ----------------------------------------------------------------------------- + +function showStatus(): void { + const status = getEffectiveRemoteModeStatus() + + log() + log(`${BOLD}Remote Test Mode${RESET}`) + log() + + if (status.isActive) { + log(` ${GREEN}โ—${RESET} ${BOLD}Enabled${RESET}`) + if (status.source === 'env') { + log(` Source: ${YELLOW}DEVFLARE_REMOTE${RESET} environment variable`) + log(` ${DIM}(unset the variable to disable)${RESET}`) + } else { + log(` Expires in ${status.remainingMinutes} minute(s)`) + log(` ${DIM}At: ${status.expiresAt?.toLocaleTimeString()}${RESET}`) + } + } else { + log(` ${DIM}โ—‹${RESET} Disabled`) + log(` ${DIM}Remote-only tests (AI, Vectorize) will be skipped${RESET}`) + } + + log() + log(`${DIM}Commands:${RESET}`) + log(` devflare remote enable [minutes] Enable for N minutes (default: 30)`) + log(` devflare remote disable Disable immediately`) + log(` devflare remote status Show current status`) + log() +} + +function enable(inputMinutes: number): void { + // enableRemoteMode clamps and validates the input + const actualMinutes = enableRemoteMode(inputMinutes) + const status = getEffectiveRemoteModeStatus() + + log() + if (inputMinutes !== actualMinutes) { + log(`${YELLOW}โš ${RESET} Invalid duration, using ${actualMinutes} minute(s)`) + } + log(`${GREEN}โœ“${RESET} Remote test mode ${BOLD}enabled${RESET} for ${actualMinutes} minute(s)`) + log(` Expires at: ${status.expiresAt?.toLocaleTimeString()}`) + log() + log(`${YELLOW}โš ${RESET} Remote tests use real Cloudflare infrastructure and may incur costs.`) + log(` Run ${DIM}devflare remote disable${RESET} when done.`) + log() +} + +function disable(): void { + const statusBefore = getEffectiveRemoteModeStatus() + disableRemoteMode() + + log() + log(`${GREEN}โœ“${RESET} Remote test mode ${BOLD}disabled${RESET}`) + + // Warn if env var still active + if (statusBefore.envVarSet) { + log() + log(`${YELLOW}โš ${RESET} Note: ${BOLD}DEVFLARE_REMOTE${RESET} environment variable is still set.`) + log(` Remote mode will remain active until you unset it.`) + } else { + log(` Remote-only tests (AI, Vectorize) will now be skipped.`) + } + log() +} + +// ----------------------------------------------------------------------------- +// Main Command +// ----------------------------------------------------------------------------- + +export function runRemoteCommand( + parsed: ParsedArgs, + _logger: ConsolaInstance, + _options: CliOptions +): CliResult { + const subcommand = parsed.args[0] as string | undefined + const arg = parsed.args[1] as string | undefined + + switch (subcommand) { + case 'enable': { + const minutes = arg ? parseInt(arg, 10) : 30 + enable(isNaN(minutes) ? 30 : minutes) + return { exitCode: 0 } + } + + case 'disable': + disable() + return { exitCode: 0 } + + case 'status': + case undefined: + showStatus() + return { exitCode: 0 } + + default: + log(`${RED}Unknown subcommand:${RESET} ${subcommand}`) + log(`Run ${DIM}devflare remote${RESET} for usage.`) + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/secrets.ts b/packages/devflare/src/cli/commands/secrets.ts new file mode 100644 index 0000000..a02a54d --- /dev/null +++ b/packages/devflare/src/cli/commands/secrets.ts @@ -0,0 +1,106 @@ +import type { ConsolaInstance } from 'consola' +import type { CliOptions, CliResult, ParsedArgs } from '../index' +import { + deleteLocalSecret, + listLocalSecrets, + writeLocalSecret +} from '../../secrets/local-secrets' + +function getStringOption( + options: Record, + key: string +): string | undefined { + const value = options[key] + return typeof value === 'string' ? value : undefined +} + +function getCwd(options: CliOptions): string { + return options.cwd ?? process.cwd() +} + +function requireLocalFlag(parsed: ParsedArgs, logger: ConsolaInstance): boolean { + if (parsed.options.local === true) { + return true + } + + logger.error('Local Secrets Store commands require --local.') + return false +} + +function requireStoreAndName( + parsed: ParsedArgs, + logger: ConsolaInstance +): { storeId: string; name: string } | undefined { + const storeId = getStringOption(parsed.options, 'store') + const name = getStringOption(parsed.options, 'name') + + if (!storeId || !name) { + logger.error('Pass --store and --name .') + return undefined + } + + return { storeId, name } +} + +function formatSecretRef(storeId: string, name: string): string { + return `${storeId}/${name}` +} + +function usage(): string { + return [ + 'devflare secrets --local --store --name --value ', + 'devflare secrets --local --store --list', + 'devflare secrets --local --store --name --delete' + ].join('\n') +} + +export function runSecretsCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): CliResult { + if (!requireLocalFlag(parsed, logger)) { + return { exitCode: 1, output: usage() } + } + + const cwd = getCwd(options) + const storeId = getStringOption(parsed.options, 'store') + + if (parsed.options.list === true) { + const rows = listLocalSecrets({ cwd, storeId }) + const output = rows.map((row) => formatSecretRef(row.storeId, row.name)).join('\n') + if (output) { + logger.info(output) + } + return { exitCode: 0, output } + } + + const required = requireStoreAndName(parsed, logger) + if (!required) { + return { exitCode: 1, output: usage() } + } + + if (parsed.options.delete === true) { + deleteLocalSecret({ cwd, storeId: required.storeId, name: required.name }) + const output = formatSecretRef(required.storeId, required.name) + logger.success(`Deleted local secret ${output}`) + return { exitCode: 0, output } + } + + const value = getStringOption(parsed.options, 'value') + if (value === undefined) { + logger.error('Pass --value , --list, or --delete.') + return { exitCode: 1, output: usage() } + } + + writeLocalSecret({ + cwd, + storeId: required.storeId, + name: required.name, + value + }) + + const output = formatSecretRef(required.storeId, required.name) + logger.success(`Stored local secret ${output}`) + return { exitCode: 0, output } +} diff --git a/packages/devflare/src/cli/commands/token.ts b/packages/devflare/src/cli/commands/token.ts new file mode 100644 index 0000000..cdd34e1 --- /dev/null +++ b/packages/devflare/src/cli/commands/token.ts @@ -0,0 +1,633 @@ +import { type ConsolaInstance } from 'consola' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { getPrimaryAccount } from '../../cloudflare/account' +import { CloudflareAPIError, AuthenticationError, type APIClientOptions } from '../../cloudflare/api' +import { getWorkspaceAccountId } from '../../cloudflare/preferences' +import type { AccountOwnedAPIToken } from '../../cloudflare/types' +import { createCliTheme, dim, green, logLine, logTable, whiteDim, yellow } from '../ui' +import { + createAccountOwnedAPIToken, + deleteAccountOwnedAPIToken, + filterDevflareManagedTokens, + listAccountOwnedAPITokens, + listAccountTokenPermissionGroups, + normalizeDevflareTokenName, + rollAccountOwnedAPITokenValue, + selectAllReusablePermissionGroups, + selectDevflarePermissionGroups, + stripDevflareTokenNamePrefix +} from '../../cloudflare/tokens' + +const CLI_API_OPTIONS: APIClientOptions = { timeout: 10000 } +const TOKENS_USAGE = 'devflare tokens (--list | --new [token-name] | --roll [token-name] | --delete [token-name] | --delete-all) [--account ] [--all-flags]' +const TOKEN_OPERATION_SUMMARY_LINES = [ + '--list List Devflare-managed account-owned tokens', + '--new [name] Create a Devflare-managed account-owned token', + '--roll [name] Roll a Devflare-managed account-owned token secret', + '--delete [name] Delete a Devflare-managed account-owned token', + '--delete-all Delete every Devflare-managed account-owned token', + '--all-flags With --new, include every reusable account/zone-scoped permission group' +] as const + +type TokenOperation = + | { kind: 'list' } + | { kind: 'new'; requestedName?: string } + | { kind: 'roll'; requestedName?: string } + | { kind: 'delete'; requestedName?: string } + | { kind: 'delete-all' } + +interface NamedManagedTokenSelection { + tokenName: string + matchingTokens: AccountOwnedAPIToken[] +} + +function getTrimmedStringOption( + options: ParsedArgs['options'], + key: string +): string | undefined { + const value = options[key] + if (typeof value !== 'string') { + return undefined + } + + const trimmedValue = value.trim() + return trimmedValue || undefined +} + +function formatTokenTimestamp(value?: Date): string { + if (!value) { + return 'โ€”' + } + + return value.toISOString().replace(/:\d{2}\.\d{3}Z$/, 'Z').replace('T', ' ') +} + +function sortTokens(tokens: AccountOwnedAPIToken[]): AccountOwnedAPIToken[] { + return [...tokens].sort((left, right) => { + const nameComparison = left.name.localeCompare(right.name) + if (nameComparison !== 0) { + return nameComparison + } + + return (right.modifiedOn?.getTime() ?? 0) - (left.modifiedOn?.getTime() ?? 0) + }) +} + + +function logUsage( + logger: ConsolaInstance, + theme: ReturnType +): void { + logLine(logger) + logLine(logger, `${dim('Usage:', theme)} ${TOKENS_USAGE}`) + logLine(logger, dim('Operations:', theme)) + for (const line of TOKEN_OPERATION_SUMMARY_LINES) { + logLine(logger, ` ${line}`) + } + logLine(logger, dim('Token names are normalized to the devflare- prefix automatically.', theme)) + logLine(logger, dim('The bootstrap token must include Cloudflare API token management permissions.', theme)) + logLine(logger) +} + +function resolveTokenOperation(parsed: ParsedArgs): TokenOperation | string { + const newOption = parsed.options.new + const rollOption = parsed.options.roll + const deleteOption = parsed.options.delete + const requestedOperations = [ + newOption !== undefined ? 'new' : null, + rollOption !== undefined ? 'roll' : null, + deleteOption !== undefined ? 'delete' : null, + parsed.options.list === true ? 'list' : null, + parsed.options['delete-all'] === true ? 'delete-all' : null + ].filter(Boolean) as Array<'new' | 'roll' | 'delete' | 'list' | 'delete-all'> + + if (parsed.options['all-flags'] && !requestedOperations.includes('new')) { + return '--all-flags can only be used together with --new.' + } + + if (requestedOperations.length === 0) { + return 'Choose one token operation: --list, --new, --roll, --delete, or --delete-all.' + } + + if (requestedOperations.length > 1) { + return 'Choose only one token operation at a time.' + } + + switch (requestedOperations[0]) { + case 'new': + return { + kind: 'new', + requestedName: typeof newOption === 'string' ? newOption.trim() || undefined : undefined + } + + case 'roll': + return { + kind: 'roll', + requestedName: typeof rollOption === 'string' ? rollOption.trim() || undefined : undefined + } + + case 'delete': + return { + kind: 'delete', + requestedName: typeof deleteOption === 'string' ? deleteOption.trim() || undefined : undefined + } + + case 'list': + return { kind: 'list' } + + case 'delete-all': + return { kind: 'delete-all' } + } +} + + +function formatManagedTokenDisplayName(name: string): string { + return stripDevflareTokenNamePrefix(name) +} +async function promptForTokenName( + logger: ConsolaInstance, + theme: ReturnType, + message: string +): Promise { + while (true) { + const selected = await logger.prompt(message, { + type: 'text', + placeholder: 'preview', + cancel: 'symbol' + }) + + if (typeof selected === 'symbol') { + logLine(logger, dim('Cancelled', theme)) + return null + } + + const trimmedValue = selected.trim() + if (trimmedValue) { + return trimmedValue + } + + logger.error('Token name is required.') + } +} + +async function resolveTokenName( + requestedName: string | undefined, + logger: ConsolaInstance, + theme: ReturnType, + promptMessage: string +): Promise { + const rawName = requestedName ?? await promptForTokenName(logger, theme, promptMessage) + if (!rawName) { + return null + } + + return normalizeDevflareTokenName(rawName) +} + +async function resolveNamedManagedTokens( + accountId: string, + accountSource: string, + bootstrapToken: string, + requestedName: string | undefined, + logger: ConsolaInstance, + theme: ReturnType, + options: { + promptMessage: string + actionLabel: string + multipleMatchMessage: string + } +): Promise { + const tokenName = await resolveTokenName( + requestedName, + logger, + theme, + options.promptMessage + ) + if (!tokenName) { + return { exitCode: 0 } + } + + logLine(logger) + logLine(logger, `${yellow('tokens', theme)} ${dim(`${options.actionLabel} a Devflare-managed account-owned tokenโ€ฆ`, theme)}`) + logLine(logger, `${dim('Account:', theme)} ${green(accountId, theme)} ${whiteDim(`(${accountSource})`, theme)}`) + logLine(logger, `${dim('Name:', theme)} ${green(tokenName, theme)}`) + + const accountTokens = await listAccountOwnedAPITokens(accountId, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + const matchingTokens = filterDevflareManagedTokens(accountTokens).filter((token) => token.name === tokenName) + + if (matchingTokens.length === 0) { + logger.error(`No Devflare-managed token named ${tokenName} was found.`) + return { exitCode: 1 } + } + + if (matchingTokens.length > 1) { + logLine( + logger, + dim(`Found ${matchingTokens.length} tokens with that name. ${options.multipleMatchMessage}.`, theme) + ) + } + + return { + tokenName, + matchingTokens + } +} + +async function resolveRequestedAccountId( + requestedAccountId: string | undefined, + bootstrapToken: string +): Promise<{ accountId: string; source: string }> { + if (requestedAccountId) { + return { accountId: requestedAccountId, source: 'flag' } + } + + const workspaceAccountId = getWorkspaceAccountId() + if (workspaceAccountId) { + return { + accountId: workspaceAccountId, + source: 'workspace' + } + } + + const primaryAccount = await getPrimaryAccount({ + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + if (!primaryAccount) { + throw new Error('No Cloudflare accounts found for this bootstrap token') + } + + return { + accountId: primaryAccount.id, + source: 'primary' + } +} + +async function createManagedToken( + accountId: string, + accountSource: string, + bootstrapToken: string, + requestedName: string | undefined, + includeAllFlags: boolean, + logger: ConsolaInstance, + theme: ReturnType +): Promise { + const tokenName = await resolveTokenName( + requestedName, + logger, + theme, + 'Enter a Devflare token name:' + ) + if (!tokenName) { + return { exitCode: 0 } + } + + logLine(logger) + logLine(logger, `${yellow('tokens', theme)} ${dim('Creating an account-owned Devflare tokenโ€ฆ', theme)}`) + logLine(logger, `${dim('Account:', theme)} ${green(accountId, theme)} ${whiteDim(`(${accountSource})`, theme)}`) + logLine(logger, `${dim('Name:', theme)} ${green(tokenName, theme)}`) + + const permissionGroups = await listAccountTokenPermissionGroups(accountId, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + + if (permissionGroups.length === 0) { + logger.error('Cloudflare returned zero account token permission groups for this account.') + return { exitCode: 1 } + } + + const selectedPermissionGroups = includeAllFlags + ? selectAllReusablePermissionGroups(permissionGroups) + : selectDevflarePermissionGroups(permissionGroups) + + const createdToken = await createAccountOwnedAPIToken( + accountId, + { + name: tokenName, + permissionGroups: selectedPermissionGroups + }, + { + ...CLI_API_OPTIONS, + token: bootstrapToken + } + ) + + if (!createdToken.value) { + logger.error('Cloudflare created the token but did not return a token value.') + return { exitCode: 1 } + } + + logger.success(`Created ${createdToken.name || tokenName}`) + logLine( + logger, + `${dim('Permission groups:', theme)} ${selectedPermissionGroups.length} ${includeAllFlags ? 'reusable account/zone-scoped' : 'Devflare-relevant account/zone-scoped'} selected from ${permissionGroups.length} available` + ) + if (includeAllFlags) { + logLine( + logger, + dim( + 'Account-owned Devflare tokens include account resources plus all zones in the account; user-scoped groups are skipped automatically.', + theme + ) + ) + logLine( + logger, + dim( + 'Account API Tokens permissions are still excluded because Cloudflare does not allow sub-tokens to manage other tokens.', + theme + ) + ) + } + logger.warn('Cloudflare only returns the token secret once. Store it safely now.') + logger.log(createdToken.value) + + return { + exitCode: 0, + output: createdToken.value + } +} + +async function listManagedTokens( + accountId: string, + accountSource: string, + bootstrapToken: string, + logger: ConsolaInstance, + theme: ReturnType +): Promise { + logLine(logger) + logLine(logger, `${yellow('tokens', theme)} ${dim('Listing Devflare-managed account-owned tokensโ€ฆ', theme)}`) + logLine(logger, `${dim('Account:', theme)} ${green(accountId, theme)} ${whiteDim(`(${accountSource})`, theme)}`) + + const accountTokens = await listAccountOwnedAPITokens(accountId, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + const managedTokens = sortTokens(filterDevflareManagedTokens(accountTokens)) + + if (managedTokens.length === 0) { + logLine(logger, dim('No Devflare-managed account-owned tokens found for this account.', theme)) + return { exitCode: 0, output: '' } + } + + logTable(logger, { + title: 'Devflare-managed tokens', + rows: managedTokens, + columns: [ + { + label: 'Name', + value: (token) => formatManagedTokenDisplayName(token.name), + width: 46 + }, + { + label: 'Status', + value: (token) => token.status ?? 'unknown', + width: 10 + }, + { + label: 'Token ID', + value: (token) => token.id.slice(0, 12), + width: 12 + }, + { + label: 'Modified', + value: (token) => formatTokenTimestamp(token.modifiedOn) + } + ], + theme + }) + + const untouchedTokenCount = accountTokens.length - managedTokens.length + if (untouchedTokenCount > 0) { + logLine( + logger, + dim(`Left ${untouchedTokenCount} non-Devflare token(s) out of this list.`, theme) + ) + } + + return { + exitCode: 0, + output: managedTokens.map((token) => formatManagedTokenDisplayName(token.name)).join('\n') + } +} + +async function rollManagedTokensByName( + accountId: string, + accountSource: string, + bootstrapToken: string, + requestedName: string | undefined, + logger: ConsolaInstance, + theme: ReturnType +): Promise { + const selectedTokens = await resolveNamedManagedTokens( + accountId, + accountSource, + bootstrapToken, + requestedName, + logger, + theme, + { + promptMessage: 'Enter the Devflare token name to roll:', + actionLabel: 'Rolling', + multipleMatchMessage: 'Rolling all exact matches' + } + ) + if ('exitCode' in selectedTokens) { + return selectedTokens + } + + const { tokenName, matchingTokens } = selectedTokens + const rolledValues: string[] = [] + for (const token of matchingTokens) { + const rolledValue = await rollAccountOwnedAPITokenValue(accountId, token.id, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + rolledValues.push(rolledValue) + } + + logger.success(`Rolled ${matchingTokens.length} Devflare-managed token(s) named ${tokenName}`) + logger.warn('Cloudflare only returns the new token secret once. Store it safely now.') + if (rolledValues.length === 1) { + logger.log(rolledValues[0]) + } else { + for (const [index, value] of rolledValues.entries()) { + logLine(logger, `${dim(`${matchingTokens[index].id.slice(0, 12)}:`, theme)} ${value}`) + } + } + + return { + exitCode: 0, + output: rolledValues.join('\n') + } +} + +async function deleteManagedTokensByName( + accountId: string, + accountSource: string, + bootstrapToken: string, + requestedName: string | undefined, + logger: ConsolaInstance, + theme: ReturnType +): Promise { + const selectedTokens = await resolveNamedManagedTokens( + accountId, + accountSource, + bootstrapToken, + requestedName, + logger, + theme, + { + promptMessage: 'Enter the Devflare token name to delete:', + actionLabel: 'Deleting', + multipleMatchMessage: 'Deleting all exact matches' + } + ) + if ('exitCode' in selectedTokens) { + return selectedTokens + } + + const { tokenName, matchingTokens } = selectedTokens + for (const token of matchingTokens) { + await deleteAccountOwnedAPIToken(accountId, token.id, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + } + + logger.success(`Deleted ${matchingTokens.length} Devflare-managed token(s) named ${tokenName}`) + + return { + exitCode: 0, + output: matchingTokens.map((token) => token.id).join('\n') + } +} + +async function deleteAllManagedTokens( + accountId: string, + accountSource: string, + bootstrapToken: string, + logger: ConsolaInstance, + theme: ReturnType +): Promise { + logLine(logger) + logLine(logger, `${yellow('tokens', theme)} ${dim('Deleting all Devflare-managed account-owned tokensโ€ฆ', theme)}`) + logLine(logger, `${dim('Account:', theme)} ${green(accountId, theme)} ${whiteDim(`(${accountSource})`, theme)}`) + + const accountTokens = await listAccountOwnedAPITokens(accountId, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + const managedTokens = filterDevflareManagedTokens(accountTokens) + + if (managedTokens.length === 0) { + logger.success('No Devflare-managed tokens were found, so nothing was deleted.') + return { exitCode: 0 } + } + + for (const token of managedTokens) { + await deleteAccountOwnedAPIToken(accountId, token.id, { + ...CLI_API_OPTIONS, + token: bootstrapToken + }) + } + + logger.success(`Deleted ${managedTokens.length} Devflare-managed token(s)`) + + const untouchedTokenCount = accountTokens.length - managedTokens.length + if (untouchedTokenCount > 0) { + logLine( + logger, + dim(`Left ${untouchedTokenCount} non-Devflare token(s) untouched.`, theme) + ) + } + + return { + exitCode: 0, + output: managedTokens.map((token) => token.id).join('\n') + } +} + +export async function runTokenCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + _options: CliOptions +): Promise { + const bootstrapToken = parsed.args[0]?.trim() + const theme = createCliTheme(parsed.options) + if (!bootstrapToken) { + logUsage(logger, theme) + return { exitCode: 1 } + } + + const tokenOperation = resolveTokenOperation(parsed) + if (typeof tokenOperation === 'string') { + logUsage(logger, theme) + return { exitCode: 1 } + } + + const requestedAccountId = getTrimmedStringOption(parsed.options, 'account') + + try { + const { accountId, source } = await resolveRequestedAccountId(requestedAccountId, bootstrapToken) + + switch (tokenOperation.kind) { + case 'new': + return createManagedToken( + accountId, + source, + bootstrapToken, + tokenOperation.requestedName, + parsed.options['all-flags'] === true, + logger, + theme + ) + + case 'roll': + return rollManagedTokensByName( + accountId, + source, + bootstrapToken, + tokenOperation.requestedName, + logger, + theme + ) + + case 'list': + return listManagedTokens(accountId, source, bootstrapToken, logger, theme) + + case 'delete': + return deleteManagedTokensByName( + accountId, + source, + bootstrapToken, + tokenOperation.requestedName, + logger, + theme + ) + + case 'delete-all': + return deleteAllManagedTokens(accountId, source, bootstrapToken, logger, theme) + } + } catch (error) { + if (error instanceof AuthenticationError) { + logger.error(error.message) + return { exitCode: 1 } + } + + if (error instanceof CloudflareAPIError) { + logger.error(`API Error: ${error.message}`) + return { exitCode: 1 } + } + + if (error instanceof Error) { + logger.error(error.message) + return { exitCode: 1 } + } + + throw error + } +} diff --git a/packages/devflare/src/cli/commands/type-generation/discovery.ts b/packages/devflare/src/cli/commands/type-generation/discovery.ts new file mode 100644 index 0000000..9fc1d26 --- /dev/null +++ b/packages/devflare/src/cli/commands/type-generation/discovery.ts @@ -0,0 +1,354 @@ +import { readFile } from 'node:fs/promises' +import { dirname, relative } from 'pathe' +import { DEFAULT_DO_PATTERN, DEFAULT_ENTRYPOINT_PATTERN, findFiles } from '../../../utils/glob' +import { discoverEntrypointsAsync, type DiscoveredEntrypoint } from '../../../utils/entrypoint-discovery' +import { findDurableObjectClasses } from '../../../transform/durable-object' +import { resolvePackageSpecifier } from '../../../utils/resolve-package' +import { resolveConfigCandidatePath } from '../../config-path' +import type { + CrossWorkerDOInfo, + DiscoveredDO, + ReferencedConfig, + ServiceBindingInfo +} from './models' + +interface ParsedConfigRef { + varName: string + importPath: string +} + +interface ParsedServiceBinding { + bindingName: string + varName: string + entrypoint?: string +} + +interface ParsedDOBinding { + bindingName: string + varName: string + doName: string +} + +interface InterfaceTypeInfo { + filePath: string + interfaceName: string +} + +const DEFAULT_INTERFACE_LOOKUP_KEYS = new Set(['Worker', 'Default', 'MathService']) +const interfaceTypeCache = new Map>>() + +async function readFileIfAvailable(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return null + } +} + +function getInterfaceSearchKey(searchDirs: string[]): string { + return [...new Set(searchDirs)] + .sort((left, right) => left.localeCompare(right)) + .join('\u0000') +} + +function getPatternMatches(pattern: RegExp, code: string): RegExpExecArray[] { + const matches: RegExpExecArray[] = [] + pattern.lastIndex = 0 + + let nextMatch = pattern.exec(code) + while (nextMatch !== null) { + matches.push(nextMatch) + nextMatch = pattern.exec(code) + } + + return matches +} + +function getInterfaceBaseName(interfaceName: string): string | null { + if (interfaceName.endsWith('Interface')) { + return interfaceName.slice(0, -9) + } + + if (interfaceName.endsWith('Rpc')) { + return interfaceName.slice(0, -3) + } + + return null +} + +function registerInterfaceType( + interfaces: Map, + baseName: string, + interfaceInfo: InterfaceTypeInfo +): void { + if (!interfaces.has(baseName)) { + interfaces.set(baseName, interfaceInfo) + } + + if (!interfaces.has('__default__') && DEFAULT_INTERFACE_LOOKUP_KEYS.has(baseName)) { + interfaces.set('__default__', interfaceInfo) + } +} + +async function collectInterfaceTypesFromFile( + interfaces: Map, + filePath: string +): Promise { + const code = await readFileIfAvailable(filePath) + if (!code) { + return + } + + const interfacePattern = /export\s+interface\s+(\w+(?:Interface|Rpc))\s*\{/g + for (const match of getPatternMatches(interfacePattern, code)) { + const interfaceName = match[1] + const baseName = getInterfaceBaseName(interfaceName) + if (!baseName) { + continue + } + + registerInterfaceType(interfaces, baseName, { + filePath, + interfaceName + }) + } +} + +async function parseConfigForRefs(configPath: string): Promise<{ + refs: ParsedConfigRef[] + serviceBindings: ParsedServiceBinding[] + doBindings: ParsedDOBinding[] +}> { + const refs: ParsedConfigRef[] = [] + const serviceBindings: ParsedServiceBinding[] = [] + const doBindings: ParsedDOBinding[] = [] + const code = await readFileIfAvailable(configPath) + + if (!code) { + return { + refs, + serviceBindings, + doBindings + } + } + + const refPattern = /const\s+(\w+)\s*=\s*ref\s*\(\s*(?:'[^']*'\s*,\s*)?(?:\(\s*\)\s*=>\s*)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/g + for (const match of getPatternMatches(refPattern, code)) { + refs.push({ + varName: match[1], + importPath: match[2] + }) + } + + const servicePattern = /(\w+)\s*:\s*(\w+)\.worker(?:\s*\(\s*['"](\w+)['"]\s*\))?/g + for (const match of getPatternMatches(servicePattern, code)) { + serviceBindings.push({ + bindingName: match[1], + varName: match[2], + entrypoint: match[3] + }) + } + + const doPattern = /(\w+)\s*:\s*(\w+)\.([A-Z][A-Z0-9_]*)\s*[,\n\r}]/g + for (const match of getPatternMatches(doPattern, code)) { + if (match[3] === 'worker') { + continue + } + + doBindings.push({ + bindingName: match[1], + varName: match[2], + doName: match[3] + }) + } + + return { + refs, + serviceBindings, + doBindings + } +} + +async function findInterfaceTypes( + searchDirs: string[] +): Promise> { + const interfaces = new Map() + + for (const dir of [...new Set(searchDirs)]) { + const typeFiles = await findFiles('**/*.types.ts', { cwd: dir }) + const srcFiles = await findFiles('src/**/*.ts', { cwd: dir }) + const allFiles = [...new Set([...typeFiles, ...srcFiles])] + + for (const filePath of allFiles) { + await collectInterfaceTypesFromFile(interfaces, filePath) + } + } + + return interfaces +} + +async function getCachedInterfaceTypes(searchDirs: string[]): Promise> { + const cacheKey = getInterfaceSearchKey(searchDirs) + const cached = interfaceTypeCache.get(cacheKey) + if (cached) { + return cached + } + + const pending = findInterfaceTypes(searchDirs) + interfaceTypeCache.set(cacheKey, pending) + + try { + return await pending + } catch (error) { + interfaceTypeCache.delete(cacheKey) + throw error + } +} + +export async function discoverDurableObjects( + cwd: string, + pattern: string = DEFAULT_DO_PATTERN +): Promise { + const discovered: DiscoveredDO[] = [] + const files = await findFiles(pattern, { cwd }) + + for (const filePath of files) { + const code = await readFileIfAvailable(filePath) + if (!code) { + continue + } + + const classNames = findDurableObjectClasses(code) + + for (const className of classNames) { + const bindingName = className + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toUpperCase() + + discovered.push({ + className, + filePath, + bindingName + }) + } + } + + return discovered +} + +export function generateImportPath(cwd: string, filePath: string): string { + let relativePath = relative(cwd, filePath) + relativePath = relativePath.replace(/\.(ts|tsx|js|jsx)$/, '') + + if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) { + relativePath = `./${relativePath}` + } + + return relativePath +} + +export async function resolveReferencedConfigs( + configPath: string, + cwd: string +): Promise { + const referenced: ReferencedConfig[] = [] + const { refs, serviceBindings, doBindings } = await parseConfigForRefs(configPath) + + if (refs.length === 0) { + return referenced + } + + const configDir = dirname(configPath) + const referencedConfigDetailsByPath = new Map + }>>() + + for (const ref of refs) { + const refImportPath = resolvePackageSpecifier(ref.importPath, configDir) + const refConfigPath = await resolveConfigCandidatePath(refImportPath) + + if (!refConfigPath) { + continue + } + + try { + let referencedConfigDetails = referencedConfigDetailsByPath.get(refConfigPath) + if (!referencedConfigDetails) { + referencedConfigDetails = (async () => { + const refDir = dirname(refConfigPath) + return { + refDir, + entrypoints: await discoverEntrypointsAsync(refDir, DEFAULT_ENTRYPOINT_PATTERN), + refDOs: await discoverDurableObjects(refDir, DEFAULT_DO_PATTERN), + interfaceMap: await getCachedInterfaceTypes([configDir, refDir]) + } + })() + referencedConfigDetailsByPath.set(refConfigPath, referencedConfigDetails) + } + + const { + refDir, + entrypoints, + refDOs, + interfaceMap + } = await referencedConfigDetails + + const bindings = serviceBindings + .filter((serviceBinding) => serviceBinding.varName === ref.varName) + .map((serviceBinding) => { + const info: ServiceBindingInfo = { + bindingName: serviceBinding.bindingName, + entrypoint: serviceBinding.entrypoint + } + const lookupKey = serviceBinding.entrypoint || '__default__' + const interfaceInfo = interfaceMap.get(lookupKey) + || (serviceBinding.entrypoint ? interfaceMap.get(serviceBinding.entrypoint) : undefined) + + if (interfaceInfo) { + info.interfaceImport = generateImportPath(cwd, interfaceInfo.filePath) + info.interfaceType = interfaceInfo.interfaceName + } + + return info + }) + + const crossWorkerDOs = doBindings + .filter((doBinding) => doBinding.varName === ref.varName) + .map((doBinding) => { + const matchingDO = refDOs.find((doInfo) => doInfo.bindingName === doBinding.doName) + if (!matchingDO) { + return null + } + + const crossWorkerDO: CrossWorkerDOInfo = { + bindingName: doBinding.bindingName, + doName: doBinding.doName, + className: matchingDO.className, + filePath: matchingDO.filePath + } + + return crossWorkerDO + }) + .filter((item): item is CrossWorkerDOInfo => item !== null) + + referenced.push({ + varName: ref.varName, + importPath: ref.importPath, + refDir, + entrypoints, + serviceBindings: bindings, + durableObjects: crossWorkerDOs + }) + } catch { + // Ignore refs whose config cannot be resolved. + } + } + + return referenced +} + +export type { DiscoveredEntrypoint } diff --git a/packages/devflare/src/cli/commands/type-generation/generator.ts b/packages/devflare/src/cli/commands/type-generation/generator.ts new file mode 100644 index 0000000..3e202e6 --- /dev/null +++ b/packages/devflare/src/cli/commands/type-generation/generator.ts @@ -0,0 +1,469 @@ +import type { + D1Binding, + DurableObjectBinding, + HyperdriveBinding, + KVBinding +} from '../../../config' +import type { DiscoveredEntrypoint } from '../../../utils/entrypoint-discovery' +import { generateImportPath } from './discovery' +import type { + CrossWorkerDOInfo, + DiscoveredDO, + ReferencedConfig, + ServiceBindingInfo +} from './models' + +interface TypeGenerationConfig { + rules?: Array<{ + type?: string + globs?: string[] + fallthrough?: boolean + }> + bindings?: { + kv?: Record + d1?: Record + r2?: Record + durableObjects?: Record + queues?: { producers?: Record; consumers?: unknown[] } + rateLimits?: Record + versionMetadata?: { binding?: string } + workerLoaders?: Record> + mtlsCertificates?: Record + dispatchNamespaces?: Record + workflows?: Record + pipelines?: Record + images?: Record + media?: Record + artifacts?: Record + secretsStore?: Record + services?: Record + ai?: { binding?: string; remote?: boolean; staging?: boolean } + aiSearchNamespaces?: Record + aiSearch?: Record + vectorize?: Record + hyperdrive?: Record + browser?: Record + analyticsEngine?: Record + sendEmail?: Record + } + vars?: Record + secrets?: Record +} + +function generateBindingMembers( + config: TypeGenerationConfig, + doClassMap: Map, + crossWorkerDOMap: Map, + serviceBindingMap: Map, + cwd: string, + indent: string, + options: { includeVarsAsMembers?: boolean } = {} +): { lines: string[]; imports: string[] } { + const lines: string[] = [] + const imports: string[] = [] + + if (config.bindings) { + if (config.bindings.kv) { + for (const binding of Object.keys(config.bindings.kv)) { + lines.push(`${indent}${binding}: KVNamespace`) + } + } + + if (config.bindings.d1) { + for (const binding of Object.keys(config.bindings.d1)) { + lines.push(`${indent}${binding}: D1Database`) + } + } + + if (config.bindings.r2) { + for (const binding of Object.keys(config.bindings.r2)) { + lines.push(`${indent}${binding}: R2Bucket`) + } + } + + if (config.bindings.durableObjects) { + for (const [binding, doConfig] of Object.entries(config.bindings.durableObjects)) { + const crossWorkerDO = crossWorkerDOMap.get(binding) + if (crossWorkerDO) { + const importPath = generateImportPath(cwd, crossWorkerDO.filePath) + lines.push(`${indent}${binding}: DurableObjectNamespace`) + continue + } + + const className = doConfig.className + if (className) { + const classInfo = doClassMap.get(className) + if (classInfo) { + lines.push(`${indent}${binding}: DurableObjectNamespace`) + continue + } + } + + lines.push(`${indent}${binding}: DurableObjectNamespace`) + } + } + + if (config.bindings.queues?.producers) { + for (const binding of Object.keys(config.bindings.queues.producers)) { + lines.push(`${indent}${binding}: Queue`) + } + } + + if (config.bindings.rateLimits) { + for (const binding of Object.keys(config.bindings.rateLimits)) { + lines.push(`${indent}${binding}: RateLimit`) + } + } + + if (config.bindings.versionMetadata?.binding) { + lines.push(`${indent}${config.bindings.versionMetadata.binding}: WorkerVersionMetadata`) + } + + if (config.bindings.workerLoaders) { + for (const binding of Object.keys(config.bindings.workerLoaders)) { + lines.push(`${indent}${binding}: WorkerLoader`) + } + } + + if (config.bindings.mtlsCertificates) { + for (const binding of Object.keys(config.bindings.mtlsCertificates)) { + lines.push(`${indent}${binding}: Fetcher`) + } + } + + if (config.bindings.dispatchNamespaces) { + for (const binding of Object.keys(config.bindings.dispatchNamespaces)) { + lines.push(`${indent}${binding}: DispatchNamespace`) + } + } + + if (config.bindings.workflows) { + for (const binding of Object.keys(config.bindings.workflows)) { + lines.push(`${indent}${binding}: Workflow`) + } + } + + if (config.bindings.pipelines) { + imports.push("import type { Pipeline } from 'cloudflare:pipelines'") + for (const binding of Object.keys(config.bindings.pipelines)) { + lines.push(`${indent}${binding}: Pipeline`) + } + } + + if (config.bindings.images) { + for (const binding of Object.keys(config.bindings.images)) { + lines.push(`${indent}${binding}: ImagesBinding`) + } + } + + if (config.bindings.media) { + for (const binding of Object.keys(config.bindings.media)) { + lines.push(`${indent}${binding}: MediaBinding`) + } + } + + if (config.bindings.artifacts) { + for (const binding of Object.keys(config.bindings.artifacts)) { + lines.push(`${indent}${binding}: Artifacts`) + } + } + + if (config.bindings.secretsStore) { + for (const binding of Object.keys(config.bindings.secretsStore)) { + lines.push(`${indent}${binding}: SecretsStoreSecret`) + } + } + + if (config.bindings.services) { + for (const binding of Object.keys(config.bindings.services)) { + const serviceInfo = serviceBindingMap.get(binding) + if (serviceInfo?.interfaceType && serviceInfo.interfaceImport) { + imports.push(`import type { ${serviceInfo.interfaceType} } from '${serviceInfo.interfaceImport}'`) + lines.push(`${indent}${binding}: ${serviceInfo.interfaceType}`) + continue + } + + lines.push(`${indent}${binding}: Fetcher`) + } + } + + if (config.bindings.ai) { + lines.push(`${indent}${config.bindings.ai.binding}: Ai`) + } + + if (config.bindings.aiSearchNamespaces) { + for (const binding of Object.keys(config.bindings.aiSearchNamespaces)) { + lines.push(`${indent}${binding}: AiSearchNamespace`) + } + } + + if (config.bindings.aiSearch) { + for (const binding of Object.keys(config.bindings.aiSearch)) { + lines.push(`${indent}${binding}: AiSearchInstance`) + } + } + + if (config.bindings.vectorize) { + for (const binding of Object.keys(config.bindings.vectorize)) { + lines.push(`${indent}${binding}: VectorizeIndex`) + } + } + + if (config.bindings.hyperdrive) { + for (const binding of Object.keys(config.bindings.hyperdrive)) { + lines.push(`${indent}${binding}: Hyperdrive`) + } + } + + if (config.bindings.browser) { + for (const binding of Object.keys(config.bindings.browser)) { + lines.push(`${indent}${binding}: Fetcher`) + } + } + + if (config.bindings.analyticsEngine) { + for (const binding of Object.keys(config.bindings.analyticsEngine)) { + lines.push(`${indent}${binding}: AnalyticsEngineDataset`) + } + } + + if (config.bindings.sendEmail) { + for (const binding of Object.keys(config.bindings.sendEmail)) { + lines.push(`${indent}${binding}: SendEmail`) + } + } + } + + if (options.includeVarsAsMembers !== false && config.vars) { + for (const key of Object.keys(config.vars)) { + lines.push(`${indent}${key}: string`) + } + } + + if (config.secrets) { + for (const secret of Object.keys(config.secrets)) { + lines.push(`${indent}${secret}: string`) + } + } + + return { lines, imports } +} + +function normalizeModuleDeclarationGlob(glob: string): string { + return glob + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/^\*\*\//, '') +} + +function generateModuleRuleDeclarations(config: TypeGenerationConfig): string[] { + const declarations: string[] = [] + const seen = new Set() + const typeByRuleType: Record = { + Text: 'string', + Data: 'ArrayBuffer', + CompiledWasm: 'WebAssembly.Module' + } + + for (const rule of config.rules ?? []) { + const valueType = rule.type ? typeByRuleType[rule.type] : undefined + if (!valueType) { + continue + } + + for (const glob of rule.globs ?? []) { + const specifier = normalizeModuleDeclarationGlob(glob) + const key = `${specifier}:${valueType}` + if (seen.has(key)) { + continue + } + + seen.add(key) + declarations.push(`declare module '${specifier}' {`) + declarations.push(`\tconst value: ${valueType}`) + declarations.push('\texport default value') + declarations.push('}') + declarations.push('') + } + } + + return declarations +} + +export function generateBindingTypes( + config: TypeGenerationConfig, + discoveredDOs: DiscoveredDO[], + discoveredEntrypoints: DiscoveredEntrypoint[], + referencedConfigs: ReferencedConfig[], + cwd: string, + options: { configImportPath?: string } = {} +): string { + const doClassMap = new Map() + for (const doInfo of discoveredDOs) { + doClassMap.set(doInfo.className, { + importPath: generateImportPath(cwd, doInfo.filePath), + className: doInfo.className + }) + } + + const crossWorkerDOMap = new Map() + for (const ref of referencedConfigs) { + for (const doInfo of ref.durableObjects) { + crossWorkerDOMap.set(doInfo.bindingName, doInfo) + } + } + + const serviceBindingMap = new Map() + for (const ref of referencedConfigs) { + for (const serviceBinding of ref.serviceBindings) { + serviceBindingMap.set(serviceBinding.bindingName, serviceBinding) + } + } + + const usedTypes = new Set() + + if (config.bindings) { + if (config.bindings.kv && Object.keys(config.bindings.kv).length > 0) usedTypes.add('KVNamespace') + if (config.bindings.d1 && Object.keys(config.bindings.d1).length > 0) usedTypes.add('D1Database') + if (config.bindings.r2 && Object.keys(config.bindings.r2).length > 0) usedTypes.add('R2Bucket') + if (config.bindings.durableObjects && Object.keys(config.bindings.durableObjects).length > 0) usedTypes.add('DurableObjectNamespace') + if (config.bindings.queues?.producers && Object.keys(config.bindings.queues.producers).length > 0) usedTypes.add('Queue') + if (config.bindings.rateLimits && Object.keys(config.bindings.rateLimits).length > 0) usedTypes.add('RateLimit') + if (config.bindings.versionMetadata?.binding) usedTypes.add('WorkerVersionMetadata') + if (config.bindings.workerLoaders && Object.keys(config.bindings.workerLoaders).length > 0) usedTypes.add('WorkerLoader') + if (config.bindings.mtlsCertificates && Object.keys(config.bindings.mtlsCertificates).length > 0) usedTypes.add('Fetcher') + if (config.bindings.dispatchNamespaces && Object.keys(config.bindings.dispatchNamespaces).length > 0) usedTypes.add('DispatchNamespace') + if (config.bindings.workflows && Object.keys(config.bindings.workflows).length > 0) usedTypes.add('Workflow') + if (config.bindings.images && Object.keys(config.bindings.images).length > 0) usedTypes.add('ImagesBinding') + if (config.bindings.media && Object.keys(config.bindings.media).length > 0) usedTypes.add('MediaBinding') + if (config.bindings.artifacts && Object.keys(config.bindings.artifacts).length > 0) usedTypes.add('Artifacts') + if (config.bindings.secretsStore && Object.keys(config.bindings.secretsStore).length > 0) usedTypes.add('SecretsStoreSecret') + if (config.bindings.services) { + const hasUntypedServices = Object.keys(config.bindings.services).some( + (name) => !serviceBindingMap.get(name)?.interfaceType + ) + if (hasUntypedServices) usedTypes.add('Fetcher') + } + if (config.bindings.ai) usedTypes.add('Ai') + if (config.bindings.aiSearchNamespaces && Object.keys(config.bindings.aiSearchNamespaces).length > 0) { + usedTypes.add('AiSearchNamespace') + } + if (config.bindings.aiSearch && Object.keys(config.bindings.aiSearch).length > 0) { + usedTypes.add('AiSearchInstance') + } + if (config.bindings.vectorize && Object.keys(config.bindings.vectorize).length > 0) usedTypes.add('VectorizeIndex') + if (config.bindings.hyperdrive && Object.keys(config.bindings.hyperdrive).length > 0) usedTypes.add('Hyperdrive') + if (config.bindings.browser && Object.keys(config.bindings.browser).length > 0) usedTypes.add('Fetcher') + if (config.bindings.analyticsEngine && Object.keys(config.bindings.analyticsEngine).length > 0) usedTypes.add('AnalyticsEngineDataset') + if (config.bindings.sendEmail && Object.keys(config.bindings.sendEmail).length > 0) usedTypes.add('SendEmail') + } + + const lines: string[] = [ + '// Generated by devflare - DO NOT EDIT', + '// Run `devflare types` to regenerate', + '' + ] + const hasConfigVars = Boolean(config.vars && Object.keys(config.vars).length > 0) + + const hasLocalDOsWithClasses = Boolean( + config.bindings?.durableObjects + && Object.values(config.bindings.durableObjects).some((doConfig) => doConfig.className && doClassMap.has(doConfig.className)) + ) + const hasCrossWorkerDOs = crossWorkerDOMap.size > 0 + const hasDOsWithClasses = hasLocalDOsWithClasses || hasCrossWorkerDOs + + if (usedTypes.size > 0) { + const sortedTypes = [...usedTypes].sort() + if (hasDOsWithClasses) { + lines.push(`import type { ${sortedTypes.join(', ')}, Rpc } from '@cloudflare/workers-types'`) + } else { + lines.push(`import type { ${sortedTypes.join(', ')} } from '@cloudflare/workers-types'`) + } + lines.push('') + } + + if (hasConfigVars) { + const configImportPath = options.configImportPath ?? './devflare.config' + lines.push("import type { InferConfigVars } from 'devflare/config'") + lines.push(`type __DevflareConfigVars = InferConfigVars>`) + lines.push('') + } + + const { lines: bindingMembers, imports: serviceImports } = generateBindingMembers( + config, + doClassMap, + crossWorkerDOMap, + serviceBindingMap, + cwd, + '\t\t', + { includeVarsAsMembers: !hasConfigVars } + ) + const uniqueImports = [...new Set(serviceImports)] + if (uniqueImports.length > 0) { + lines.push(...uniqueImports) + lines.push('') + } + + lines.push('declare global {') + if (hasConfigVars) { + lines.push('\tinterface DevflareVars extends __DevflareConfigVars {}') + lines.push('\tinterface DevflareEnv extends __DevflareConfigVars {') + } else { + lines.push('\tinterface DevflareEnv {') + } + lines.push(...bindingMembers) + lines.push('\t}') + lines.push('}') + lines.push('') + + lines.push(...generateModuleRuleDeclarations(config)) + + if (discoveredEntrypoints.length > 0) { + const entrypointNames = discoveredEntrypoints.map((entrypoint) => `'${entrypoint.className}'`).join(' | ') + lines.push('/**') + lines.push(' * Named entrypoints discovered from ep.*.ts files.') + lines.push(' * Use with defineConfig() for type-safe cross-worker references.') + lines.push(' */') + lines.push(`export type Entrypoints = ${entrypointNames}`) + } else { + lines.push('/**') + lines.push(' * Named entrypoints (none discovered - add ep.*.ts files to enable).') + lines.push(' * Use with defineConfig() for type-safe cross-worker references.') + lines.push(' */') + lines.push('export type Entrypoints = string') + } + lines.push('') + + return lines.join('\n') +} diff --git a/packages/devflare/src/cli/commands/type-generation/models.ts b/packages/devflare/src/cli/commands/type-generation/models.ts new file mode 100644 index 0000000..2430b65 --- /dev/null +++ b/packages/devflare/src/cli/commands/type-generation/models.ts @@ -0,0 +1,30 @@ +import type { DiscoveredEntrypoint } from '../../../utils/entrypoint-discovery' + +export interface DiscoveredDO { + className: string + filePath: string + bindingName: string +} + +export interface ServiceBindingInfo { + bindingName: string + entrypoint?: string + interfaceImport?: string + interfaceType?: string +} + +export interface CrossWorkerDOInfo { + bindingName: string + doName: string + className: string + filePath: string +} + +export interface ReferencedConfig { + varName: string + importPath: string + refDir: string + entrypoints: DiscoveredEntrypoint[] + serviceBindings: ServiceBindingInfo[] + durableObjects: CrossWorkerDOInfo[] +} diff --git a/packages/devflare/src/cli/commands/types.ts b/packages/devflare/src/cli/commands/types.ts new file mode 100644 index 0000000..1d9c33b --- /dev/null +++ b/packages/devflare/src/cli/commands/types.ts @@ -0,0 +1,158 @@ +// ============================================================================= +// Types Command โ€” Generate TypeScript types from config +// ============================================================================= + +import { type ConsolaInstance } from 'consola' +import { resolve } from 'pathe' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { loadConfig, normalizeDOBinding } from '../../config' +import { getDependencies } from '../dependencies' +import { DEFAULT_DO_PATTERN, DEFAULT_ENTRYPOINT_PATTERN } from '../../utils/glob' +import { discoverEntrypointsAsync, type DiscoveredEntrypoint } from '../../utils/entrypoint-discovery' +import { resolveConfigCandidatePath } from '../config-path' +import { bold, createCliTheme, dim } from '../ui' +import { discoverDurableObjects, generateImportPath, resolveReferencedConfigs } from './type-generation/discovery' +import { generateBindingTypes } from './type-generation/generator' +import type { DiscoveredDO } from './type-generation/models' + +function logTypesLine(logger: ConsolaInstance, message: string = ''): void { + if (typeof logger.log === 'function') { + logger.log(message) + return + } + + if (typeof logger.info === 'function') { + logger.info(message) + } +} + +export async function runTypesCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd || process.cwd() + const configPath = parsed.options.config as string | undefined + const outputPath = (parsed.options.output as string) || 'env.d.ts' + const theme = createCliTheme(parsed.options) + + logTypesLine(logger) + logTypesLine(logger, `${bold('types', theme)} ${dim('Generating TypeScript bindings', theme)}`) + + try { + const config = await loadConfig({ cwd, configFile: configPath }) + const requestedConfigPath = configPath + ? resolve(cwd, configPath) + : cwd + const actualConfigPath = await resolveConfigCandidatePath(requestedConfigPath) + + if (!actualConfigPath) { + throw new Error('Could not resolve the loaded devflare config file path') + } + + const doPattern = typeof config.files?.durableObjects === 'string' + ? config.files.durableObjects + : DEFAULT_DO_PATTERN + + let discoveredDOs: DiscoveredDO[] = [] + if (config.files?.durableObjects !== false) { + discoveredDOs = await discoverDurableObjects(cwd, doPattern) + if (discoveredDOs.length > 0) { + logTypesLine(logger, `Discovered ${discoveredDOs.length} Durable Object class(es):`) + for (const doInfo of discoveredDOs) { + logTypesLine(logger, ` โ€ข ${doInfo.className} โ†’ ${doInfo.bindingName}`) + } + } + } + + if (config.bindings?.durableObjects) { + for (const [bindingName, doConfig] of Object.entries(config.bindings.durableObjects)) { + const normalized = normalizeDOBinding(doConfig) + const className = normalized.className + if (!className) { + continue + } + + const existing = discoveredDOs.find((doInfo) => doInfo.className === className) + if (existing) { + continue + } + + if (normalized.scriptName && (normalized.scriptName.endsWith('.ts') || normalized.scriptName.endsWith('.js'))) { + const filePath = resolve(cwd, 'src', normalized.scriptName) + discoveredDOs.push({ + className, + filePath, + bindingName + }) + } + } + } + + const entrypointPattern = typeof config.files?.entrypoints === 'string' + ? config.files.entrypoints + : DEFAULT_ENTRYPOINT_PATTERN + + let discoveredEntrypoints: DiscoveredEntrypoint[] = [] + if (config.files?.entrypoints !== false) { + discoveredEntrypoints = await discoverEntrypointsAsync(cwd, entrypointPattern) + if (discoveredEntrypoints.length > 0) { + logTypesLine(logger, `Discovered ${discoveredEntrypoints.length} entrypoint class(es):`) + for (const entrypoint of discoveredEntrypoints) { + logTypesLine(logger, ` โ€ข ${entrypoint.className}`) + } + } + } + + const referencedConfigs = await resolveReferencedConfigs(actualConfigPath, cwd) + if (referencedConfigs.length > 0) { + logTypesLine(logger, `Found ${referencedConfigs.length} referenced worker(s):`) + for (const ref of referencedConfigs) { + const typedBindings = ref.serviceBindings.filter((serviceBinding) => serviceBinding.interfaceType) + if (typedBindings.length > 0) { + logTypesLine(logger, ` โ€ข ${ref.varName}: ${typedBindings.map((serviceBinding) => `${serviceBinding.bindingName} โ†’ ${serviceBinding.interfaceType}`).join(', ')}`) + } + } + } + + const normalizedConfig = { + ...config, + bindings: config.bindings ? { + ...config.bindings, + durableObjects: config.bindings.durableObjects + ? Object.fromEntries( + Object.entries(config.bindings.durableObjects).map(([name, doConfig]) => { + const normalized = normalizeDOBinding(doConfig) + return [name, { className: normalized.className, scriptName: normalized.scriptName }] + }) + ) + : undefined + } : undefined + } + + const types = generateBindingTypes( + normalizedConfig, + discoveredDOs, + discoveredEntrypoints, + referencedConfigs, + cwd, + { configImportPath: generateImportPath(cwd, actualConfigPath) } + ) + + const { fs } = await getDependencies() + const fullPath = resolve(cwd, outputPath) + await fs.writeFile(fullPath, types, 'utf-8') + + logger.success(`Generated types: ${outputPath}`) + return { exitCode: 0 } + } catch (error) { + if (error instanceof Error) { + logger.error('Type generation failed:', error.message) + if (parsed.options.debug) { + logger.error(error.stack) + } + } + + return { exitCode: 1 } + } +} diff --git a/packages/devflare/src/cli/commands/worker.ts b/packages/devflare/src/cli/commands/worker.ts new file mode 100644 index 0000000..ec0c366 --- /dev/null +++ b/packages/devflare/src/cli/commands/worker.ts @@ -0,0 +1,654 @@ +import { type ConsolaInstance } from 'consola' +import MagicString from 'magic-string' +import { basename, dirname, relative, resolve } from 'pathe' +import type { ParsedArgs, CliOptions, CliResult } from '../index' +import { account } from '../../cloudflare' +import { loadConfig, normalizeDOBinding, type DevflareConfig } from '../../config' +import { + findConfigPathsUnderDirectory, + formatSupportedConfigFilenames, + resolveConfigCandidatePath +} from '../config-path' +import { asOptionalString, resolveCloudflareAccountId } from '../command-utils' +import { bold, createCliTheme, dim, green, logLine, whiteDim, yellow } from '../ui' + +interface LoadedConfigRecord { + configPath: string + config: DevflareConfig +} + +interface WorkerReferenceHit { + configPath: string + scope: string + kind: 'service' | 'durable-object' + bindingName: string +} + +interface ConfigSelectionResult { + target: LoadedConfigRecord + localConfigAlreadyUpdated: boolean + allConfigs: LoadedConfigRecord[] +} + +function formatPathForLog(cwd: string, filePath: string): string { + const relativePath = relative(cwd, filePath).replace(/\\/g, '/') + return relativePath && !relativePath.startsWith('..') ? relativePath : filePath +} + +async function loadConfigFromPath(configPath: string): Promise { + return { + configPath, + config: await loadConfig({ + cwd: dirname(configPath), + configFile: basename(configPath) + }) + } +} + +async function loadDiscoveredConfigs(cwd: string, explicitConfigPath?: string): Promise { + const candidatePaths = new Set() + const discoveredPaths = await findConfigPathsUnderDirectory(cwd) + for (const configPath of discoveredPaths) { + candidatePaths.add(configPath) + } + + if (explicitConfigPath) { + candidatePaths.add(explicitConfigPath) + } + + const loadedConfigs: LoadedConfigRecord[] = [] + for (const configPath of [...candidatePaths].sort((left, right) => left.localeCompare(right))) { + try { + loadedConfigs.push(await loadConfigFromPath(configPath)) + } catch (error) { + if (explicitConfigPath && explicitConfigPath === configPath) { + throw error + } + } + } + + return loadedConfigs +} + +function formatConfigChoices(matches: LoadedConfigRecord[], cwd: string): string { + return matches.map((match) => ` - ${formatPathForLog(cwd, match.configPath)} (${match.config.name})`).join('\n') +} + +function selectTargetConfig( + loadedConfigs: LoadedConfigRecord[], + cwd: string, + oldName: string, + newName: string, + explicitConfigPath?: string +): ConfigSelectionResult { + if (loadedConfigs.length === 0) { + throw new Error( + `Could not find ${formatSupportedConfigFilenames()} under ${cwd}.` + ) + } + + if (explicitConfigPath) { + const target = loadedConfigs.find((candidate) => candidate.configPath === explicitConfigPath) + if (!target) { + throw new Error(`Could not load the selected config: ${explicitConfigPath}`) + } + + if (target.config.name === oldName) { + return { + target, + localConfigAlreadyUpdated: false, + allConfigs: loadedConfigs + } + } + + if (target.config.name === newName) { + return { + target, + localConfigAlreadyUpdated: true, + allConfigs: loadedConfigs + } + } + + throw new Error( + `The selected config uses \`${target.config.name}\`, not \`${oldName}\` or \`${newName}\`.` + ) + } + + const matchingConfigs = loadedConfigs.filter((candidate) => { + return candidate.config.name === oldName || candidate.config.name === newName + }) + const oldMatches = matchingConfigs.filter((candidate) => candidate.config.name === oldName) + const newMatches = matchingConfigs.filter((candidate) => candidate.config.name === newName) + + if (oldMatches.length === 1 && matchingConfigs.length === 1) { + return { + target: oldMatches[0], + localConfigAlreadyUpdated: false, + allConfigs: loadedConfigs + } + } + + if (newMatches.length === 1 && matchingConfigs.length === 1) { + return { + target: newMatches[0], + localConfigAlreadyUpdated: true, + allConfigs: loadedConfigs + } + } + + if (matchingConfigs.length === 0) { + throw new Error( + `Could not find a matching devflare config under ${cwd}. Expected a config whose \`name\` is \`${oldName}\` or \`${newName}\`.` + ) + } + + throw new Error( + `Multiple matching devflare configs were found. Use --config to pick one explicitly.\n${formatConfigChoices(matchingConfigs, cwd)}` + ) +} + +async function resolveAccountId( + parsed: ParsedArgs, + config: DevflareConfig +): Promise { + return resolveCloudflareAccountId({ + explicitAccountId: asOptionalString(parsed.options.account), + configuredAccountId: config.accountId + }) +} + +function skipWhitespaceAndComments(source: string, start: number, end: number): number { + let index = start + + while (index < end) { + const char = source[index] + if (/\s/.test(char)) { + index++ + continue + } + + const nextIndex = consumeComment(source, index, end) + if (nextIndex !== null) { + index = nextIndex + continue + } + + break + } + + return index +} + +function consumeQuotedLiteral(source: string, start: number, end: number): number { + const quote = source[start] + let index = start + 1 + + while (index < end) { + const char = source[index] + if (char === '\\') { + index += 2 + continue + } + + if (char === quote) { + return index + 1 + } + + index++ + } + + throw new Error('Unterminated string literal in devflare config.') +} + +function consumeComment(source: string, start: number, end: number): number | null { + if (source[start] !== '/') { + return null + } + + if (source[start + 1] === '/') { + let index = start + 2 + while (index < end && source[index] !== '\n') { + index++ + } + return index + } + + if (source[start + 1] === '*') { + let index = start + 2 + while (index < end && !(source[index] === '*' && source[index + 1] === '/')) { + index++ + } + return Math.min(index + 2, end) + } + + return null +} + +function findConfigObjectStart(source: string): number { + const defineConfigIndex = source.indexOf('defineConfig') + if (defineConfigIndex >= 0) { + const parenIndex = source.indexOf('(', defineConfigIndex) + if (parenIndex >= 0) { + const objectIndex = source.indexOf('{', parenIndex) + if (objectIndex >= 0) { + return objectIndex + } + } + } + + const exportDefaultIndex = source.indexOf('export default') + if (exportDefaultIndex >= 0) { + const objectIndex = source.indexOf('{', exportDefaultIndex) + if (objectIndex >= 0) { + return objectIndex + } + } + + return -1 +} + +function getRootPropertySlices(source: string, objectStart: number): Array<{ start: number; end: number }> { + const slices: Array<{ start: number; end: number }> = [] + let curlyDepth = 1 + let squareDepth = 0 + let parenDepth = 0 + let propertyStart = objectStart + 1 + let index = objectStart + 1 + + while (index < source.length) { + const char = source[index] + + if (char === '\'' || char === '"' || char === '`') { + index = consumeQuotedLiteral(source, index, source.length) + continue + } + + const nextIndex = consumeComment(source, index, source.length) + if (nextIndex !== null) { + index = nextIndex + continue + } + + if (char === '{') { + curlyDepth++ + index++ + continue + } + + if (char === '}') { + curlyDepth-- + if (curlyDepth === 0) { + slices.push({ start: propertyStart, end: index }) + return slices + } + index++ + continue + } + + if (char === '[') { + squareDepth++ + index++ + continue + } + + if (char === ']') { + squareDepth-- + index++ + continue + } + + if (char === '(') { + parenDepth++ + index++ + continue + } + + if (char === ')') { + parenDepth-- + index++ + continue + } + + if (char === ',' && curlyDepth === 1 && squareDepth === 0 && parenDepth === 0) { + slices.push({ start: propertyStart, end: index }) + propertyStart = index + 1 + } + + index++ + } + + throw new Error('Could not parse the root object in devflare config.') +} + +function findTopLevelColon(source: string, start: number, end: number): number { + let curlyDepth = 0 + let squareDepth = 0 + let parenDepth = 0 + let index = start + + while (index < end) { + const char = source[index] + + if (char === '\'' || char === '"' || char === '`') { + index = consumeQuotedLiteral(source, index, end) + continue + } + + const nextIndex = consumeComment(source, index, end) + if (nextIndex !== null) { + index = nextIndex + continue + } + + if (char === '{') { + curlyDepth++ + index++ + continue + } + + if (char === '}') { + curlyDepth-- + index++ + continue + } + + if (char === '[') { + squareDepth++ + index++ + continue + } + + if (char === ']') { + squareDepth-- + index++ + continue + } + + if (char === '(') { + parenDepth++ + index++ + continue + } + + if (char === ')') { + parenDepth-- + index++ + continue + } + + if (char === ':' && curlyDepth === 0 && squareDepth === 0 && parenDepth === 0) { + return index + } + + index++ + } + + return -1 +} + +function normalizePropertyKey(rawKey: string): string { + const trimmed = rawKey.trim() + if ( + (trimmed.startsWith('\'') && trimmed.endsWith('\'')) + || (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1) + } + return trimmed +} + +function findRootNameLiteralRange(source: string): { start: number; end: number; quote: '\'' | '"' } | null { + const objectStart = findConfigObjectStart(source) + if (objectStart < 0) { + return null + } + + for (const slice of getRootPropertySlices(source, objectStart)) { + const keyStart = skipWhitespaceAndComments(source, slice.start, slice.end) + const colonIndex = findTopLevelColon(source, keyStart, slice.end) + if (colonIndex < 0) { + continue + } + + const key = normalizePropertyKey(source.slice(keyStart, colonIndex)) + if (key !== 'name') { + continue + } + + const valueStart = skipWhitespaceAndComments(source, colonIndex + 1, slice.end) + const quote = source[valueStart] + if (quote !== '\'' && quote !== '"') { + throw new Error('The top-level `name` property must be a string literal to be updated automatically.') + } + + return { + start: valueStart, + end: consumeQuotedLiteral(source, valueStart, slice.end), + quote + } + } + + return null +} + +function quoteWorkerName(value: string, quote: '\'' | '"'): string { + const escapedValue = value + .replace(/\\/g, '\\\\') + .replace(new RegExp(`\\${quote}`, 'g'), `\\${quote}`) + + return `${quote}${escapedValue}${quote}` +} + +async function updateConfigName(configPath: string, newName: string): Promise { + const fs = await import('node:fs/promises') + const source = await fs.readFile(configPath, 'utf-8') + const literalRange = findRootNameLiteralRange(source) + if (!literalRange) { + throw new Error('Could not locate a top-level string literal `name` property in the selected devflare config.') + } + + const magicString = new MagicString(source) + magicString.overwrite( + literalRange.start, + literalRange.end, + quoteWorkerName(newName, literalRange.quote) + ) + await fs.writeFile(configPath, magicString.toString(), 'utf-8') +} + +function collectReferenceHitsFromConfig( + configPath: string, + configLike: Pick | undefined, + oldName: string, + scope: string +): WorkerReferenceHit[] { + if (!configLike?.bindings) { + return [] + } + + const hits: WorkerReferenceHit[] = [] + + for (const [bindingName, bindingConfig] of Object.entries(configLike.bindings.services ?? {})) { + if (bindingConfig.service === oldName) { + hits.push({ + configPath, + scope, + kind: 'service', + bindingName + }) + } + } + + for (const [bindingName, bindingConfig] of Object.entries(configLike.bindings.durableObjects ?? {})) { + const normalized = normalizeDOBinding(bindingConfig) + if (normalized.scriptName === oldName) { + hits.push({ + configPath, + scope, + kind: 'durable-object', + bindingName + }) + } + } + + return hits +} + +function collectReferenceHits( + loadedConfigs: LoadedConfigRecord[], + oldName: string +): WorkerReferenceHit[] { + const hits: WorkerReferenceHit[] = [] + + for (const record of loadedConfigs) { + hits.push(...collectReferenceHitsFromConfig(record.configPath, record.config, oldName, 'root')) + + for (const [envName, envConfig] of Object.entries(record.config.env ?? {})) { + hits.push( + ...collectReferenceHitsFromConfig( + record.configPath, + envConfig as Pick, + oldName, + `env.${envName}` + ) + ) + } + } + + return hits +} + +export async function runWorkerCommand( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const cwd = options.cwd ?? process.cwd() + const theme = createCliTheme(parsed.options) + const subcommand = parsed.args[0] + const oldName = parsed.args[1]?.trim() + const newName = asOptionalString(parsed.options.to) + const explicitConfigPath = asOptionalString(parsed.options.config) + + if (subcommand !== 'rename') { + logger.error(`Unknown worker subcommand: ${subcommand ?? ''}`) + logLine(logger, dim('Usage: devflare worker rename --to [--config ]', theme)) + return { exitCode: 1 } + } + + if (!oldName) { + logger.error('A current Worker name is required.') + logLine(logger, dim('Usage: devflare worker rename --to [--config ]', theme)) + return { exitCode: 1 } + } + + if (!newName) { + logger.error('The new Worker name must be provided with --to.') + return { exitCode: 1 } + } + + if (oldName === newName) { + logger.error('The new Worker name must be different from the current name.') + return { exitCode: 1 } + } + + logLine(logger) + logLine(logger, `${yellow('worker', theme)} ${dim('Renaming Worker identity', theme)}`) + logLine(logger, `${dim('from', theme)} ${whiteDim(oldName, theme)}`) + logLine(logger, `${dim('to', theme)} ${green(newName, theme)}`) + + if (!await account.isAuthenticated()) { + logger.error('Not authenticated with Cloudflare') + logLine(logger, dim('Run `devflare login` first.', theme)) + return { exitCode: 1 } + } + + let remoteRenamed = false + + try { + const explicitResolvedConfigPath = explicitConfigPath + ? await resolveConfigCandidatePath(resolve(cwd, explicitConfigPath)) + : null + + if (explicitConfigPath && !explicitResolvedConfigPath) { + throw new Error( + `${formatSupportedConfigFilenames()} not found for --config ${explicitConfigPath}.` + ) + } + + const selection = selectTargetConfig( + await loadDiscoveredConfigs(cwd, explicitResolvedConfigPath ?? undefined), + cwd, + oldName, + newName, + explicitResolvedConfigPath ?? undefined + ) + const { target, localConfigAlreadyUpdated, allConfigs } = selection + const accountId = await resolveAccountId(parsed, target.config) + if (!accountId) { + logger.error('No Cloudflare account could be resolved for this config.') + logLine(logger, dim('Set accountId in devflare.config.ts, pass --account, or configure a default account.', theme)) + return { exitCode: 1 } + } + + logLine(logger, `${dim('config', theme)} ${whiteDim(formatPathForLog(cwd, target.configPath), theme)}`) + logLine(logger, `${dim('account', theme)} ${whiteDim(accountId, theme)}`) + logLine(logger) + + const workers = await account.workers(accountId) + const hasOldWorker = workers.some((worker) => worker.name === oldName) + const hasNewWorker = workers.some((worker) => worker.name === newName) + + if (hasOldWorker && hasNewWorker) { + logger.error(`Both \`${oldName}\` and \`${newName}\` already exist in Cloudflare.`) + logLine(logger, dim('Refusing to rename because the target Worker name is already taken.', theme)) + return { exitCode: 1 } + } + + if (!hasOldWorker && !hasNewWorker) { + logger.error(`Neither \`${oldName}\` nor \`${newName}\` exists in Cloudflare for account ${accountId}.`) + return { exitCode: 1 } + } + + if (hasOldWorker && !hasNewWorker) { + await account.renameWorker(accountId, oldName, newName) + remoteRenamed = true + logger.success(`Renamed remote Worker ${oldName} โ†’ ${newName}`) + } else { + logLine(logger, `${dim('remote', theme)} ${green(newName, theme)} ${dim('is already the active Worker name in Cloudflare', theme)}`) + } + + if (!localConfigAlreadyUpdated) { + await updateConfigName(target.configPath, newName) + logger.success(`Updated ${formatPathForLog(cwd, target.configPath)}`) + } else { + logLine(logger, `${dim('config', theme)} ${green('already updated locally', theme)}`) + } + + const referenceHits = collectReferenceHits(allConfigs, oldName) + if (referenceHits.length > 0) { + logger.warn(`Found ${referenceHits.length} local reference(s) that still use \`${oldName}\`.`) + for (const hit of referenceHits) { + logLine(logger, ` ${formatPathForLog(cwd, hit.configPath)} ${dim(`(${hit.scope})`, theme)} ${dim('โ€”', theme)} ${hit.kind === 'service' ? 'service binding' : 'durable object binding'} ${bold(hit.bindingName, theme)}`) + } + } + + logLine(logger) + logLine(logger, `${yellow('preview urls', theme)} ${dim('Existing preview URLs and registry entries may continue using the old Worker name until you upload fresh previews for the renamed Worker.', theme)}`) + logLine(logger, dim('Future deploys and preview uploads from this config will target the new Worker name.', theme)) + + return { exitCode: 0 } + } catch (error) { + if (remoteRenamed) { + logger.warn('The remote Worker rename succeeded, but the local config update did not complete.') + logLine(logger, dim('Update devflare.config.ts manually so future deploys target the renamed Worker.', theme)) + } + + if (error instanceof Error) { + logger.error(error.message) + return { exitCode: 1 } + } + + throw error + } +} diff --git a/packages/devflare/src/cli/config-path.ts b/packages/devflare/src/cli/config-path.ts new file mode 100644 index 0000000..68672e4 --- /dev/null +++ b/packages/devflare/src/cli/config-path.ts @@ -0,0 +1,59 @@ +import { stat } from 'node:fs/promises' +import { resolveConfigPath } from '../config/loader' +import { findFiles } from '../utils/glob' + +export const CONFIG_FILE_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'] as const +export const SUPPORTED_CONFIG_FILENAMES = CONFIG_FILE_EXTENSIONS.map( + (extension) => `devflare.config${extension}` +) + +function hasKnownConfigExtension(filePath: string): boolean { + return CONFIG_FILE_EXTENSIONS.some((extension) => filePath.endsWith(extension)) +} + +async function getExistingFilePath(filePath: string): Promise { + try { + const fileStat = await stat(filePath) + return fileStat.isFile() ? filePath : null + } catch { + return null + } +} + +export async function resolveConfigCandidatePath(candidatePath: string): Promise { + const candidates = [candidatePath] + + if (!hasKnownConfigExtension(candidatePath)) { + for (const extension of CONFIG_FILE_EXTENSIONS) { + candidates.push(`${candidatePath}${extension}`) + } + } + + for (const candidate of candidates) { + const existingFilePath = await getExistingFilePath(candidate) + if (existingFilePath) { + return existingFilePath + } + } + + return await resolveConfigPath(candidatePath) ?? null +} + +export async function findConfigPathsUnderDirectory(rootDir: string): Promise { + const matches = await findFiles( + [ + ...SUPPORTED_CONFIG_FILENAMES, + ...SUPPORTED_CONFIG_FILENAMES.map((filename) => `**/${filename}`) + ], + { + cwd: rootDir, + absolute: true + } + ) + + return [...new Set(matches)].sort((left, right) => left.localeCompare(right)) +} + +export function formatSupportedConfigFilenames(): string { + return SUPPORTED_CONFIG_FILENAMES.join(', ') +} diff --git a/packages/devflare/src/cli/dependencies.ts b/packages/devflare/src/cli/dependencies.ts new file mode 100644 index 0000000..27d6d51 --- /dev/null +++ b/packages/devflare/src/cli/dependencies.ts @@ -0,0 +1,157 @@ +// ============================================================================= +// CLI Dependencies โ€” Injectable filesystem and process utilities +// ============================================================================= + +import type { PathLike, MakeDirectoryOptions, Stats } from 'node:fs' +import type { Result, Options as ExecaOptions } from 'execa' + +/** + * Filesystem abstraction for CLI commands + */ +export interface FileSystem { + readFile(path: PathLike, encoding: BufferEncoding): Promise + readFile(path: PathLike, options?: { encoding?: BufferEncoding }): Promise + writeFile(path: PathLike, data: string | Buffer, encoding?: BufferEncoding): Promise + mkdir(path: PathLike, options?: MakeDirectoryOptions): Promise + access(path: PathLike, mode?: number): Promise + stat(path: PathLike): Promise + readdir(path: PathLike, options?: { withFileTypes?: boolean }): Promise> + rm(path: PathLike, options?: { recursive?: boolean; force?: boolean }): Promise + unlink(path: PathLike): Promise +} + +/** + * Exec result type (compatible with execa Result) + */ +export interface ExecResult { + exitCode: number + stdout: string + stderr: string + failed: boolean + killed: boolean + signal?: string +} + +/** + * Spawned process handle + */ +export interface SpawnedProcess { + pid?: number + stdout: NodeJS.ReadableStream | null + stderr: NodeJS.ReadableStream | null + /** Whether the process has been killed */ + readonly killed: boolean + kill(signal?: NodeJS.Signals): boolean + on(event: 'exit', handler: (code: number | null) => void): SpawnedProcess + on(event: 'error', handler: (err: Error) => void): SpawnedProcess +} + +/** + * Process execution abstraction for CLI commands + */ +export interface ProcessRunner { + exec( + command: string, + args?: string[], + options?: ExecaOptions + ): Promise + spawn( + command: string, + args?: string[], + options?: { cwd?: string; stdio?: any; env?: NodeJS.ProcessEnv; shell?: boolean } + ): SpawnedProcess +} + +/** + * CLI dependencies container + */ +export interface CliDependencies { + fs: FileSystem + exec: ProcessRunner +} + +/** + * Create real dependencies using actual fs and execa + */ +export async function createRealDependencies(): Promise { + const fs = await import('node:fs/promises') + const { execa, execaCommand } = await import('execa') + const { spawn } = await import('node:child_process') + + return { + fs: fs as unknown as FileSystem, + exec: { + exec: async (command, args = [], options = {}) => { + const result = await execa(command, args, options) + return { + exitCode: result.exitCode ?? 0, + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + failed: result.failed, + killed: false, + signal: result.signal as string | undefined + } + }, + spawn: (command, args = [], options = {}) => { + // Note: `shell` defaults to false. Callers that legitimately need shell + // interpretation (rare) must opt in by passing `shell: true` explicitly. + // Passing shell:true with untrusted input is a command-injection risk. + const child = spawn(command, args, { + cwd: options.cwd, + stdio: options.stdio ?? 'pipe', + env: options.env, + shell: options.shell ?? false + }) + // Create wrapper with getter for killed property + const wrapper: SpawnedProcess = { + pid: child.pid, + stdout: child.stdout, + stderr: child.stderr, + get killed() { + return child.killed + }, + kill: (signal?: NodeJS.Signals) => child.kill(signal), + on: (event: string, handler: any) => { + child.on(event, handler) + return wrapper + } + } + return wrapper + } + } + } +} + +// Global dependencies instance (can be overridden for testing) +let _deps: CliDependencies | null = null + +/** + * Get CLI dependencies (lazy initialization) + */ +export async function getDependencies(): Promise { + if (!_deps) { + _deps = await createRealDependencies() + } + return _deps +} + +/** + * Set CLI dependencies (for testing) + */ +export function setDependencies(deps: CliDependencies): void { + _deps = deps +} + +/** + * Reset CLI dependencies to real implementations + */ +export async function resetDependencies(): Promise { + _deps = await createRealDependencies() +} + +/** + * Clear dependencies (force re-initialization) + */ +export function clearDependencies(): void { + _deps = null +} diff --git a/packages/devflare/src/cli/deploy-strategy.ts b/packages/devflare/src/cli/deploy-strategy.ts new file mode 100644 index 0000000..253e922 --- /dev/null +++ b/packages/devflare/src/cli/deploy-strategy.ts @@ -0,0 +1,126 @@ +import type { DevflareConfig } from '../config' + +export type DeploymentStrategy = 'default' | 'preview-scope' + +export interface ApplyDeploymentStrategyOptions { + environment?: string + preview?: boolean + branchName?: string + previewBranch?: string +} + +export interface AppliedDeploymentStrategy { + config: TConfig + strategy: DeploymentStrategy + branchScope?: string + omittedResources: Array<'queue-consumers' | 'cron-triggers'> +} + +function normalizeBranchScope(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function shouldIncludePreviewCrons(config: DevflareConfig): boolean { + return config.previews?.includeCrons === true +} + +function omitQueueConsumers(config: DevflareConfig): DevflareConfig { + if (!config.bindings?.queues?.consumers?.length) { + return config + } + + const nextBindings = { + ...config.bindings + } + const nextQueues = { + ...nextBindings.queues + } + + delete nextQueues.consumers + + if (!nextQueues.producers || Object.keys(nextQueues.producers).length === 0) { + delete nextBindings.queues + } else { + nextBindings.queues = nextQueues + } + + return { + ...config, + bindings: nextBindings + } +} + +function omitCronTriggers(config: DevflareConfig): DevflareConfig { + if (!config.triggers?.crons?.length) { + return config + } + + const nextTriggers = { + ...config.triggers + } + + delete nextTriggers.crons + + if (Object.keys(nextTriggers).length === 0) { + const { triggers: _triggers, ...rest } = config + return rest + } + + return { + ...config, + triggers: nextTriggers + } +} + +export function applyDeploymentStrategy( + config: TConfig, + options: ApplyDeploymentStrategyOptions = {} +): AppliedDeploymentStrategy { + const branchScope = normalizeBranchScope(options.previewBranch) ?? normalizeBranchScope(options.branchName) + const isBranchScopedPreviewDeploy = !options.preview && options.environment === 'preview' && Boolean(branchScope) + + if (!isBranchScopedPreviewDeploy) { + return { + config, + strategy: 'default', + omittedResources: [] + } + } + + const omittedResources: AppliedDeploymentStrategy['omittedResources'] = [] + let nextConfig: DevflareConfig = config + + if (nextConfig.bindings?.queues?.consumers?.length) { + nextConfig = omitQueueConsumers(nextConfig) + omittedResources.push('queue-consumers') + } + + if (!shouldIncludePreviewCrons(nextConfig) && nextConfig.triggers?.crons?.length) { + nextConfig = omitCronTriggers(nextConfig) + omittedResources.push('cron-triggers') + } + + return { + config: nextConfig as TConfig, + strategy: 'preview-scope', + branchScope, + omittedResources + } +} + +export function describeDeploymentStrategy(result: AppliedDeploymentStrategy): string | undefined { + if (result.strategy !== 'preview-scope' || result.omittedResources.length === 0) { + return undefined + } + + const labels = result.omittedResources.map((resource) => { + return resource === 'queue-consumers' ? 'queue consumers' : 'cron triggers' + }) + const formattedLabels = labels.length === 2 + ? `${labels[0]} and ${labels[1]}` + : labels[0] + const scopeSuffix = result.branchScope ? ` (${result.branchScope})` : '' + + return `Named preview-scope deploy detected${scopeSuffix}; omitting shared ${formattedLabels} from the deployed Wrangler config to avoid singleton Cloudflare resource conflicts.` +} \ No newline at end of file diff --git a/packages/devflare/src/cli/deploy-target.ts b/packages/devflare/src/cli/deploy-target.ts new file mode 100644 index 0000000..819f1c2 --- /dev/null +++ b/packages/devflare/src/cli/deploy-target.ts @@ -0,0 +1,160 @@ +import type { ParsedArgs } from './index' +import { resolvePreviewIdentifier } from '../config' +import { asOptionalString } from './command-utils' + +export type DeployTargetMode = 'implicit' | 'production' | 'preview-upload' | 'preview-scope' + +export interface ResolvedDeployTarget { + mode: DeployTargetMode + environment?: string + targetFlag?: '--prod' | '--production' | '--preview' + previewScope?: string + previewScopeRaw?: string + envOverrides: Record +} + +export interface ResolveDeployTargetOptions { + requireExplicitTarget?: boolean +} + +export function resolveDeployTarget( + parsed: ParsedArgs, + options: ResolveDeployTargetOptions = {} +): ResolvedDeployTarget { + const wantsProduction = parsed.options.prod === true || parsed.options.production === true + const previewOption = parsed.options.preview + const previewScopeRaw = asOptionalString(previewOption) + const wantsPreview = previewOption === true || Boolean(previewScopeRaw) + + if (!wantsProduction && !wantsPreview) { + if (options.requireExplicitTarget === true) { + throw new Error( + 'Deploy needs an explicit target. Use --prod / --production for live traffic, or --preview (or bare --preview) for preview deploys.' + ) + } + + return { + mode: 'implicit', + envOverrides: {} + } + } + + if (wantsProduction && wantsPreview) { + throw new Error('Choose either --prod / --production or --preview , not both.') + } + + const explicitEnvironment = asOptionalString(parsed.options.env) + + if (wantsProduction) { + if (explicitEnvironment && explicitEnvironment !== 'production') { + throw new Error( + 'Production deploys always target the production environment. Remove --env or use --env production.' + ) + } + + if (parsed.options['branch-name'] !== undefined) { + throw new Error('Production deploys do not accept --branch-name.') + } + + return { + mode: 'production', + environment: 'production', + targetFlag: parsed.options.production === true ? '--production' : '--prod', + envOverrides: { + DEVFLARE_PREVIEW_BRANCH: undefined, + DEVFLARE_PREVIEW_IDENTIFIER: undefined, + DEVFLARE_PREVIEW_PR: undefined + } + } + } + + if (explicitEnvironment && explicitEnvironment !== 'preview') { + throw new Error( + 'Preview deploys always target the preview environment. Remove --env or use --env preview.' + ) + } + + if (previewScopeRaw) { + const branchName = asOptionalString(parsed.options['branch-name']) + if (branchName && branchName !== previewScopeRaw) { + throw new Error( + 'Named preview deploys use the --preview value as the preview scope. Omit --branch-name or pass the same value to both flags.' + ) + } + + const previewScope = resolvePreviewIdentifier({ + identifier: previewScopeRaw + }).identifier ?? 'preview' + + return { + mode: 'preview-scope', + environment: 'preview', + targetFlag: '--preview', + previewScope, + previewScopeRaw, + envOverrides: { + DEVFLARE_PREVIEW_BRANCH: previewScopeRaw, + DEVFLARE_PREVIEW_IDENTIFIER: previewScope, + DEVFLARE_PREVIEW_PR: undefined + } + } + } + + return { + mode: 'preview-upload', + environment: 'preview', + targetFlag: '--preview', + envOverrides: { + DEVFLARE_PREVIEW_BRANCH: undefined, + DEVFLARE_PREVIEW_IDENTIFIER: undefined, + DEVFLARE_PREVIEW_PR: undefined + } + } +} + +export function applyResolvedDeployTarget( + parsed: ParsedArgs, + target: ResolvedDeployTarget +): ParsedArgs { + if (!target.environment) { + return parsed + } + + return { + ...parsed, + options: { + ...parsed.options, + env: target.environment + } + } +} + +export async function withTemporaryEnvironment( + overrides: Record, + operation: () => Promise +): Promise { + const previousValues = new Map() + + for (const [key, value] of Object.entries(overrides)) { + previousValues.set(key, process.env[key]) + if (typeof value === 'string') { + process.env[key] = value + continue + } + + delete process.env[key] + } + + try { + return await operation() + } finally { + for (const [key, value] of previousValues) { + if (typeof value === 'string') { + process.env[key] = value + continue + } + + delete process.env[key] + } + } +} diff --git a/packages/devflare/src/cli/generated-artifacts.ts b/packages/devflare/src/cli/generated-artifacts.ts new file mode 100644 index 0000000..42bb4f0 --- /dev/null +++ b/packages/devflare/src/cli/generated-artifacts.ts @@ -0,0 +1,50 @@ +import { resolve } from 'pathe' + +const DEVFLARE_DIR = ['.devflare'] as const +const DEVFLARE_BUILD_DIR = ['.devflare', 'build'] as const +const WRANGLER_DEPLOY_DIR = ['.wrangler', 'deploy'] as const + +export interface GeneratedArtifactPaths { + devflareDir: string + devWranglerConfigPath: string + buildDir: string + buildWorkerPath: string + buildWranglerConfigPath: string + deployDir: string + deployRedirectPath: string +} + +export function getGeneratedArtifactPaths(cwd: string): GeneratedArtifactPaths { + const devflareDir = resolve(cwd, ...DEVFLARE_DIR) + const buildDir = resolve(cwd, ...DEVFLARE_BUILD_DIR) + const deployDir = resolve(cwd, ...WRANGLER_DEPLOY_DIR) + + return { + devflareDir, + devWranglerConfigPath: resolve(devflareDir, 'wrangler.jsonc'), + buildDir, + buildWorkerPath: resolve(buildDir, 'worker.js'), + buildWranglerConfigPath: resolve(buildDir, 'wrangler.jsonc'), + deployDir, + deployRedirectPath: resolve(deployDir, 'config.json') + } +} + +export async function ensureGeneratedDirectory( + dirPath: string, + writeGitignore: boolean = false +): Promise { + const fs = await import('node:fs/promises') + await fs.mkdir(dirPath, { recursive: true }) + + if (!writeGitignore) { + return + } + + const gitignorePath = resolve(dirPath, '.gitignore') + try { + await fs.access(gitignorePath) + } catch { + await fs.writeFile(gitignorePath, '*\n', 'utf-8') + } +} \ No newline at end of file diff --git a/packages/devflare/src/cli/help-pages/pages/account.ts b/packages/devflare/src/cli/help-pages/pages/account.ts new file mode 100644 index 0000000..9b34093 --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/account.ts @@ -0,0 +1,171 @@ +import type { HelpPage } from '../types' +import { + ACCOUNT_OPTION, + createAccountInventoryPage, + entry +} from '../shared' + +export const ACCOUNT_HELP_PAGES: HelpPage[] = [ + { + path: ['account'], + summary: 'Inspect Cloudflare accounts, resources, and usage data', + usage: [ + 'devflare account [info] [--account ]', + 'devflare account [--account ]', + 'devflare account limits [set | enable | disable] [--account ]', + 'devflare account ' + ], + description: [ + 'The default view shows the selected account and the commands you can run against it.', + 'Inventory subcommands list Cloudflare resources in the resolved account, while `limits` and `usage` focus on Devflare-managed usage controls.' + ], + subcommands: [ + entry('info', 'Show account overview and available account commands (default)'), + entry('workers', 'List Workers for the selected account'), + entry('kv', 'List KV namespaces'), + entry('d1', 'List D1 databases'), + entry('r2', 'List R2 buckets'), + entry('vectorize', 'List Vectorize indexes'), + entry('usage', 'Show Devflare usage summaries'), + entry('limits', 'Show or change Devflare usage limits'), + entry('global', 'Choose the global default account interactively'), + entry('workspace', 'Choose the workspace account interactively') + ], + options: [ACCOUNT_OPTION], + examples: [ + entry('devflare account', 'Show the account overview'), + entry('devflare account workers', 'List Workers for the resolved account'), + entry('devflare account limits set ai-requests 50', 'Set a daily AI request limit'), + entry('devflare account workspace', 'Choose the account stored in the current workspace') + ], + notes: [ + 'Account resolution prefers `--account`, then workspace settings, then `CLOUDFLARE_ACCOUNT_ID`, then the loaded config, then the primary authenticated account.', + 'The `global` and `workspace` subcommands are interactive selectors; they do not take `--account`.' + ] + }, + createAccountInventoryPage('info', 'Show the selected account overview', 'Displays the selected account plus shortcuts for other `account` subcommands.', 'Show the account overview and suggested follow-up commands'), + createAccountInventoryPage('workers', 'List Workers in the selected account', 'Prints Worker names and last-modified timestamps for the resolved account.', 'List Worker scripts in the selected account'), + createAccountInventoryPage('kv', 'List KV namespaces in the selected account', 'Prints namespace names and ids for the resolved account.', 'List KV namespaces for the selected account'), + createAccountInventoryPage('d1', 'List D1 databases in the selected account', 'Prints database names, ids, and table counts for the resolved account.', 'List D1 databases for the selected account'), + createAccountInventoryPage('r2', 'List R2 buckets in the selected account', 'Prints bucket names, creation dates, and locations for the resolved account.', 'List R2 buckets for the selected account'), + createAccountInventoryPage('vectorize', 'List Vectorize indexes in the selected account', 'Prints Vectorize index names, dimensions, and metrics for the resolved account.', 'List Vectorize indexes for the selected account'), + createAccountInventoryPage('usage', 'Show Devflare usage summaries', 'Prints Devflare-tracked usage totals and the currently configured usage limits.', 'Show usage summaries for the selected account'), + { + path: ['account', 'limits'], + summary: 'Show or update Devflare usage limits', + usage: [ + 'devflare account limits [--account ]', + 'devflare account limits set [--account ]', + 'devflare account limits [--account ]' + ], + description: [ + 'Views the current Devflare usage limits for the selected account and optionally updates them.', + 'Use `enable` or `disable` to toggle enforcement without changing the configured numeric thresholds.' + ], + subcommands: [ + entry('set ', 'Set one numeric usage limit'), + entry('enable', 'Enable limit enforcement without changing stored values'), + entry('disable', 'Disable limit enforcement without changing stored values') + ], + options: [ACCOUNT_OPTION], + examples: [ + entry('devflare account limits', 'Show the current usage limits'), + entry('devflare account limits set ai-requests 50', 'Set the daily AI request limit'), + entry('devflare account limits enable', 'Enable limit enforcement'), + entry('devflare account limits disable', 'Disable limit enforcement') + ], + notes: [ + 'Valid limit names are `ai-requests`, `ai-tokens`, and `vectorize-ops`.', + 'Numeric values must be non-negative integers.' + ] + }, + { + path: ['account', 'limits', 'set'], + summary: 'Set one Devflare usage limit', + usage: [ + 'devflare account limits set [--account ]' + ], + description: [ + 'Updates one stored Devflare usage limit for the selected account.', + 'Use `enable` separately when you want enforcement turned on after changing the threshold.' + ], + arguments: [ + entry('', 'Which usage limit to update'), + entry('', 'Non-negative integer threshold for the selected limit') + ], + options: [ACCOUNT_OPTION], + examples: [ + entry('devflare account limits set ai-requests 50', 'Set the daily AI request limit to 50'), + entry('devflare account limits set ai-tokens 5000 --account ', 'Set the daily AI token limit for a specific account') + ], + notes: [ + 'Changing a value does not automatically enable enforcement if limits are currently disabled.' + ] + }, + { + path: ['account', 'limits', 'enable'], + summary: 'Enable Devflare usage-limit enforcement', + usage: [ + 'devflare account limits enable [--account ]' + ], + description: [ + 'Turns on Devflare usage-limit enforcement for the selected account without changing the stored numeric thresholds.' + ], + options: [ACCOUNT_OPTION], + examples: [ + entry('devflare account limits enable', 'Enable usage-limit enforcement for the resolved account') + ], + notes: [ + 'Use `devflare account limits set ...` first when you need to change the stored thresholds.' + ] + }, + { + path: ['account', 'limits', 'disable'], + summary: 'Disable Devflare usage-limit enforcement', + usage: [ + 'devflare account limits disable [--account ]' + ], + description: [ + 'Disables Devflare usage-limit enforcement for the selected account without deleting the stored thresholds.' + ], + options: [ACCOUNT_OPTION], + examples: [ + entry('devflare account limits disable', 'Disable usage-limit enforcement for the resolved account') + ], + notes: [ + 'Re-enable later with `devflare account limits enable` to reuse the same stored values.' + ] + }, + { + path: ['account', 'global'], + summary: 'Choose the global default Cloudflare account', + usage: [ + 'devflare account global' + ], + description: [ + 'Opens an interactive selector and stores the chosen default account in Devflare preferences.' + ], + examples: [ + entry('devflare account global', 'Choose the global default account interactively') + ], + notes: [ + 'The selected account is written to Devflare preferences and mirrored to cloud KV when available.' + ] + }, + { + path: ['account', 'workspace'], + summary: 'Choose the workspace Cloudflare account', + usage: [ + 'devflare account workspace' + ], + description: [ + 'Opens an interactive selector and stores the chosen account in the current workspace package metadata.' + ], + examples: [ + entry('devflare account workspace', 'Choose the account pinned to the current workspace') + ], + notes: [ + 'The workspace account overrides the global default when Devflare resolves account context inside that workspace.' + ] + } +] diff --git a/packages/devflare/src/cli/help-pages/pages/core.ts b/packages/devflare/src/cli/help-pages/pages/core.ts new file mode 100644 index 0000000..823c746 --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/core.ts @@ -0,0 +1,370 @@ +import type { HelpPage } from '../types' +import { COMMANDS, COMMON_OPTIONS, entry } from '../shared' + +export const CORE_HELP_PAGES: HelpPage[] = [ + { + path: [], + summary: 'Config compiler + CLI orchestrator for Cloudflare Workers', + usage: [ + 'devflare [options]' + ], + description: [ + 'Use `devflare --help` or `devflare help ` to see a detailed command guide.', + 'Devflare commands resolve local config first, then bridge that config to Wrangler-compatible Cloudflare workflows.' + ], + subcommands: [ + entry('init [name]', 'Create a new devflare project'), + entry('dev', 'Start the development server'), + entry('build', 'Build for production'), + entry('deploy', 'Deploy explicitly to production or preview'), + entry('types', 'Generate TypeScript types'), + entry('doctor', 'Check project configuration'), + entry('config', 'Print resolved Devflare/Wrangler config'), + entry('account', 'View Cloudflare account info and resource inventories'), + entry('login', 'Authenticate with Cloudflare via Wrangler'), + entry('previews', 'Inspect and clean dedicated preview Workers and scopes'), + entry('productions', 'Inspect and manage live production Workers and deployments'), + entry('worker', 'Rename and manage Worker control-plane operations'), + entry('tokens', 'Manage Devflare-managed Cloudflare API tokens'), + entry('secrets', 'Manage local Secrets Store values'), + entry('ai', 'View Workers AI pricing information'), + entry('remote', 'Manage remote test mode for paid Cloudflare features'), + entry('help', 'Show command overview or a command-specific help page'), + entry('version', 'Show the installed devflare version') + ], + options: COMMON_OPTIONS, + optionSectionTitle: 'common options', + examples: [ + entry('devflare dev', 'Start worker-only or unified local development'), + entry('devflare deploy --prod', 'Deploy explicitly to production'), + entry('devflare deploy --preview next', 'Deploy a named preview scope directly'), + entry('devflare previews cleanup --scope next --apply', 'Delete one dedicated preview scope and its preview-owned resources'), + entry('devflare productions', 'Inspect live production Workers and active deployments'), + entry('devflare secrets --local --store store-123 --name api-token --value local-token', 'Set a local Secrets Store value'), + entry('devflare help deploy', 'Show the detailed deploy help page') + ], + notes: [ + 'Commands that support `--config` and `--env` document that explicitly in their own help pages.' + ] + }, + { + path: ['init'], + summary: 'Create a new devflare project', + usage: [ + 'devflare init [name] [--template ]' + ], + description: [ + 'Scaffolds a new project directory with a starter `devflare.config.ts`, TypeScript config, and package.json scripts.', + 'Use the `api` template when you want middleware and API routing structure out of the box.' + ], + arguments: [ + entry('[name]', 'Project directory name (defaults to `my-devflare-app`)') + ], + options: [ + entry('--template ', 'Pick the starter template to scaffold') + ], + examples: [ + entry('devflare init my-app', 'Create a minimal starter called `my-app`'), + entry('devflare init edge-api --template api', 'Create the API starter with middleware structure') + ], + notes: [ + 'The command writes files only; install dependencies afterward with `bun install`.', + 'Generated starter scripts expect `devflare dev`, `devflare build`, `devflare deploy`, and `devflare types`.' + ] + }, + { + path: ['dev'], + summary: 'Start the development server', + usage: [ + 'devflare dev [--config ] [--port ] [--runtime-port ] [--bridge-port ] [--persist] [--verbose] [--debug] [--log | --log-temp]' + ], + description: [ + 'Starts a worker-only Miniflare server by default, and automatically enables Vite when the current package has an effective local Vite setup.', + 'Also watches Worker and Durable Object source files, rebuilding and hot-reloading them as they change.', + 'Referenced service bindings declared with `ref()` are started in the same local Miniflare runtime so full-stack packages can call their local API workers.' + ], + options: [ + entry('--config ', 'Use a specific devflare config file'), + entry('--port ', 'Preferred Vite dev server port (defaults to 5173 when Vite is enabled)'), + entry('--runtime-port ', 'Preferred local Miniflare runtime/bridge port (defaults to 8787)'), + entry('--bridge-port ', 'Alias for --runtime-port; also honored via DEVFLARE_BRIDGE_PORT'), + entry('--persist', 'Persist Miniflare storage between restarts'), + entry('--verbose', 'Increase logging verbosity'), + entry('--debug', 'Enable extra debug logging and stack traces'), + entry('--log', 'Mirror dev output into a timestamped `.log-*` file'), + entry('--log-temp', 'Mirror dev output into `.log`, overwriting the file each run') + ], + examples: [ + entry('devflare dev', 'Start local development with automatic Vite detection'), + entry('devflare dev --port 3000', 'Use a custom Vite port when Vite is enabled'), + entry('devflare dev --runtime-port 8788', 'Use a custom local runtime port when another project owns 8787'), + entry('devflare dev --persist --log-temp', 'Keep Miniflare state and overwrite `.log` on each run') + ], + notes: [ + 'Worker-only mode is the default when no effective local `vite.config.*` is present.', + 'When no CLI port option is provided, Devflare reads DEVFLARE_RUNTIME_PORT and then DEVFLARE_BRIDGE_PORT before falling back to 8787.', + '`--log` and `--log-temp` still print to the terminal; they add a file mirror instead of redirecting output away.' + ] + }, + { + path: ['build'], + summary: 'Build production deployment artifacts', + usage: [ + 'devflare build [--config ] [--env ] [--debug]' + ], + description: [ + 'Resolves your Devflare config locally, applies environment overrides, and generates the build artifacts used by deploy flows.', + 'Build preserves named bindings instead of provisioning Cloudflare resources, so it is the safest way to inspect what Devflare will hand to deploy before you actually ship.' + ], + options: [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` before building artifacts'), + entry('--debug', 'Print stack traces when build preparation fails') + ], + examples: [ + entry('devflare build', 'Build the default environment'), + entry('devflare build --env production', 'Build using `config.env.production`') + ], + notes: [ + 'Build currently writes `.devflare/wrangler.jsonc`, `.devflare/build/wrangler.jsonc`, and `.wrangler/deploy/config.json`.', + 'Build does not query or provision Cloudflare account resources on its own; name-based bindings stay as names in the generated artifacts until deploy time.', + '`devflare deploy` runs the same artifact preparation step automatically before invoking Wrangler, unless you provide `--build ` to reuse an existing build artifact.' + ] + }, + { + path: ['deploy'], + summary: 'Deploy explicitly to Cloudflare production or preview targets', + usage: [ + 'devflare deploy --prod [--config ] [--build ] [--message ] [--tag ] [--debug]', + 'devflare deploy --production [--config ] [--build ] [--message ] [--tag ] [--debug]', + 'devflare deploy --preview [--config ] [--build ] [--message ] [--tag ]', + 'devflare deploy --preview [--config ] [--build ] [--branch-name ] [--message ] [--tag ]', + 'devflare deploy --prod --dry-run [--config ]', + 'devflare deploy --preview --dry-run [--config ]', + 'devflare deploy --preview --dry-run [--config ]' + ], + description: [ + 'Deploy requires an explicit target: production via `--prod` / `--production`, or preview via `--preview`.', + 'Named preview deploys such as `--preview next` or `--preview pr-1` target `config.env.preview`, provision preview-scoped resources automatically, and deploy dedicated preview-scope Workers when your config is wired for them. Bare `--preview` keeps the same-worker preview upload flow and can still use `--branch-name` or CI/git metadata for preview-aware naming.', + 'Production and named preview deploys also provision missing deploy-time resources such as KV namespaces, D1 databases, R2 buckets, and Queues when Devflare has enough information to create them.' + ], + options: [ + entry('--prod', 'Deploy to the production environment explicitly'), + entry('--production', 'Long-form alias for --prod'), + entry('--preview', 'Deploy a same-worker preview upload'), + entry('--preview ', 'Deploy a named preview scope such as `next` or `pr-1`'), + entry('--config ', 'Use a specific devflare config file'), + entry('--build ', 'Reuse an existing build artifact such as `.devflare/build` or `.wrangler/deploy/config.json` instead of rebuilding'), + entry('--env ', 'Usually unnecessary because the explicit target already pins production vs preview. If you pass it, it must match that target'), + entry('--dry-run', 'Print the synthesized Wrangler config and skip the actual deployment'), + entry('--branch-name ', 'Provide explicit branch metadata for preview-aware naming when your workflow needs it'), + entry('--message ', 'Attach an explicit Wrangler deployment/version message'), + entry('--tag ', 'Attach an explicit Wrangler version tag'), + entry('--debug', 'Print stack traces when deployment orchestration fails') + ], + examples: [ + entry('devflare deploy --prod', 'Deploy explicitly to production'), + entry('devflare deploy --prod --build .devflare/build', 'Deploy a previously built artifact without rebuilding the package'), + entry('devflare deploy --production --message "Release"', 'Deploy to production with an explicit deployment message'), + entry('devflare deploy --preview next', 'Deploy the named `next` preview scope and provision preview-scoped resources automatically'), + entry('devflare deploy --preview pr-1', 'Deploy the named `pr-1` preview scope directly'), + entry('devflare deploy --preview --branch-name feature-branch', 'Upload a same-worker preview version with explicit branch metadata'), + entry('devflare deploy --preview next --dry-run', 'Inspect the generated named-preview Wrangler config without deploying') + ], + notes: [ + '`devflare deploy` without an explicit target is rejected from the CLI so production and preview destinations stay unmistakable.', + '`--prod` / `--production` clear preview-scope environment overrides such as `DEVFLARE_PREVIEW_BRANCH` so production deploys stay pointed at stable Worker names.', + 'Named preview deploys automatically provision preview-scoped resources before building and deploying.', + 'When a build artifact still contains name-based bindings, deploy resolves or provisions the concrete Cloudflare resources and rewrites the generated Wrangler config with the IDs Wrangler requires.', + 'Plain `--preview` still uses Cloudflare preview uploads, so it cannot be the first-ever upload for a brand-new Worker, preview URLs remain limited for Workers that implement Durable Objects, and preview uploads do not apply Durable Object migrations.' + ] + }, + { + path: ['secrets'], + summary: 'Manage local Secrets Store values', + usage: [ + 'devflare secrets --local --store --name --value ', + 'devflare secrets --local --store --list', + 'devflare secrets --local --store --name --delete' + ], + description: [ + 'Writes, lists, and deletes local values for Secrets Store bindings used by dev, createTestContext(), and createOfflineEnv({ cwd }).', + 'The runtime side is read-only: Workers can read configured local values through the Secrets Store binding, but application code cannot mutate this file.' + ], + options: [ + entry('--local', 'Use the project-local secret file instead of Cloudflare'), + entry('--store ', 'Secrets Store ID, matching the Cloudflare account store ID when you have one'), + entry('--name ', 'Secret name inside the store'), + entry('--value ', 'Secret value to write locally'), + entry('--list', 'List local secret names without printing values'), + entry('--delete', 'Delete one local secret value') + ], + examples: [ + entry('devflare secrets --local --store store-123 --name api-token --value local-token', 'Create or replace one local secret value'), + entry('devflare secrets --local --store store-123 --list', 'List names in one local store'), + entry('devflare secrets --local --store store-123 --name api-token --delete', 'Delete one local secret value') + ], + notes: [ + 'Local values are stored in `.devflare/secrets.local.json`, which is ignored by the repository template.', + 'Command output prints store/name references only; it does not echo secret values.' + ] + }, + { + path: ['types'], + summary: 'Generate TypeScript bindings from your config', + usage: [ + 'devflare types [--config ] [--output ] [--debug]' + ], + description: [ + 'Generates `env.d.ts`-style bindings for KV, D1, R2, Durable Objects, Queues, service bindings, vars, and secrets.', + 'Devflare also discovers entrypoints and cross-worker Durable Objects so service RPC bindings can stay strongly typed.' + ], + options: [ + entry('--config ', 'Use a specific devflare config file'), + entry('--output ', 'Write generated types to a custom path (defaults to `env.d.ts`)'), + entry('--debug', 'Print stack traces when type generation fails') + ], + examples: [ + entry('devflare types', 'Generate `env.d.ts` next to the current project'), + entry('devflare types --output src/generated/env.d.ts', 'Write generated bindings to a custom file') + ], + notes: [ + 'Type discovery respects configured file patterns and falls back to the default Durable Object and entrypoint glob patterns.', + 'Re-run this command after adding or renaming bindings, Durable Objects, or cross-worker service references.' + ] + }, + { + path: ['doctor'], + summary: 'Check project configuration', + usage: [ + 'devflare doctor [--config ] [--scope ]' + ], + description: [ + 'Checks for a loadable devflare config, package.json, TypeScript config, Vite integration, and generated Wrangler artifacts.', + 'Useful when a project feels cursed but not cursed enough to throw a clear error yet.' + ], + options: [ + entry('--config ', 'Check a specific config path instead of the default resolution path'), + entry('--scope ', 'Choose local-only checks, deploy-readiness checks, or both (defaults to all)') + ], + examples: [ + entry('devflare doctor', 'Run diagnostics for the current package'), + entry('devflare doctor --scope local', 'Skip deploy artifact readiness checks during local-only development'), + entry('devflare doctor --config apps/docs/devflare.config.ts', 'Check a specific config file') + ], + notes: [ + 'Warnings still return exit code 0; hard failures return exit code 1.', + 'The command reports whether generated `.devflare` and `.wrangler/deploy` artifacts already exist.', + '`@cloudflare/vite-plugin` is optional for Devflare Vite/SvelteKit projects unless your own Vite config calls that plugin directly.' + ] + }, + { + path: ['config'], + summary: 'Print resolved Devflare or Wrangler config', + usage: [ + 'devflare config [print] [--config ] [--env ] [--phase ] [--local] [--format ]' + ], + description: [ + 'Loads the effective config and prints it as JSON.', + 'Use `--format wrangler` when you want to inspect the exact Wrangler-compatible config Devflare will emit.', + 'Use `--phase local` or `--local` for offline local-runtime inspection without resolving Cloudflare account resource names.' + ], + subcommands: [ + entry('print', 'Print the resolved config (default subcommand)') + ], + options: [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` before printing'), + entry('--phase ', 'Choose build/offline, local-runtime, or deploy resource resolution (defaults to deploy)'), + entry('--local', 'Shortcut for --phase local'), + entry('--format ', 'Choose whether to print raw Devflare config or compiled Wrangler JSON') + ], + examples: [ + entry('devflare config', 'Print the resolved Devflare config as JSON'), + entry('devflare config --phase local --format wrangler', 'Print local-runtime Wrangler JSON without Cloudflare account lookups'), + entry('devflare config --env preview', 'Print the preview environment config'), + entry('devflare config print --format wrangler', 'Print the compiled Wrangler config JSON') + ], + notes: [ + '`print` is the default subcommand, so `devflare config` and `devflare config print` behave the same.', + 'Deploy phase remains the default for backwards compatibility; local and build phases are offline-friendly inspection modes.' + ] + }, + { + path: ['config', 'print'], + summary: 'Print the resolved config', + usage: [ + 'devflare config print [--config ] [--env ] [--phase ] [--local] [--format ]' + ], + description: [ + 'Equivalent to `devflare config`, but spelled out explicitly when you want the subcommand in scripts or docs.' + ], + options: [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` before printing'), + entry('--phase ', 'Choose build/offline, local-runtime, or deploy resource resolution (defaults to deploy)'), + entry('--local', 'Shortcut for --phase local'), + entry('--format ', 'Choose Devflare JSON or compiled Wrangler JSON output') + ], + examples: [ + entry('devflare config print --format wrangler', 'Print the compiled Wrangler config'), + entry('devflare config print --local --format wrangler', 'Print the local-runtime Wrangler config without account lookups') + ] + }, + { + path: ['login'], + summary: 'Authenticate with Cloudflare via Wrangler', + usage: [ + 'devflare login [--force]' + ], + description: [ + 'Uses Wrangler login under the hood, then reports the resolved primary or configured account context.' + ], + options: [ + entry('--force', 'Open Wrangler login even when Devflare already sees an authenticated session') + ], + examples: [ + entry('devflare login', 'Authenticate only when needed'), + entry('devflare login --force', 'Re-open Wrangler login even if already authenticated') + ], + notes: [ + 'If you are already authenticated and omit `--force`, Devflare will reuse the current credentials instead of reopening login.' + ] + }, + { + path: ['help'], + summary: 'Show command overview or command-specific help', + usage: [ + 'devflare help', + 'devflare help [subcommand]' + ], + description: [ + 'Prints the root command overview or the detailed help page for a specific command path.' + ], + examples: [ + entry('devflare help', 'Show the root command overview'), + entry('devflare help previews', 'Show the detailed previews help page'), + entry('devflare help previews cleanup', 'Show nested help for a preview subcommand when available') + ], + notes: [ + '`devflare --help` resolves to the same detailed help page as `devflare help `.' + ] + }, + { + path: ['version'], + summary: 'Show the installed devflare version', + usage: [ + 'devflare version', + 'devflare --version' + ], + description: [ + 'Prints the installed package version and exits.' + ], + examples: [ + entry('devflare version', 'Show the installed version'), + entry('devflare --version', 'Show the installed version using the global flag') + ] + } +] + +export const CORE_COMMANDS = COMMANDS diff --git a/packages/devflare/src/cli/help-pages/pages/index.ts b/packages/devflare/src/cli/help-pages/pages/index.ts new file mode 100644 index 0000000..272e85f --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/index.ts @@ -0,0 +1,14 @@ +import type { HelpPage } from '../types' +import { ACCOUNT_HELP_PAGES } from './account' +import { CORE_HELP_PAGES } from './core' +import { MISC_HELP_PAGES } from './misc' +import { PREVIEW_HELP_PAGES } from './previews' +import { PRODUCTION_HELP_PAGES } from './productions' + +export const HELP_PAGES: HelpPage[] = [ + ...CORE_HELP_PAGES, + ...ACCOUNT_HELP_PAGES, + ...PREVIEW_HELP_PAGES, + ...PRODUCTION_HELP_PAGES, + ...MISC_HELP_PAGES +] diff --git a/packages/devflare/src/cli/help-pages/pages/misc.ts b/packages/devflare/src/cli/help-pages/pages/misc.ts new file mode 100644 index 0000000..4aa516f --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/misc.ts @@ -0,0 +1,183 @@ +import type { HelpPage } from '../types' +import { + createRemoteSubcommandPage, + entry +} from '../shared' + +export const MISC_HELP_PAGES: HelpPage[] = [ + { + path: ['worker'], + summary: 'Rename and manage Worker control-plane operations', + usage: [ + 'devflare worker rename --to [--config ] [--account ]' + ], + description: [ + 'Currently, `rename` is the supported worker control-plane operation.', + 'Devflare renames the remote Worker when needed and updates the matching local config name when it can resolve it safely.' + ], + subcommands: [ + entry('rename', 'Rename a Worker and sync the matching devflare config name') + ], + options: [ + entry('--to ', 'New Worker name for the `rename` subcommand'), + entry('--config ', 'Choose the config file to update when multiple configs might match'), + entry('--account ', 'Use a specific Cloudflare account when renaming the remote Worker') + ], + examples: [ + entry('devflare worker rename documentation --to devflare-documentation', 'Rename a Worker and sync the matching config') + ], + notes: [ + 'Devflare warns about local service binding and Durable Object references that still point at the old Worker name so you can update them manually if needed.' + ] + }, + { + path: ['worker', 'rename'], + summary: 'Rename a Worker and sync the matching config', + usage: [ + 'devflare worker rename --to [--config ] [--account ]' + ], + description: [ + 'Renames the remote Worker when necessary, updates the top-level `name` field in the selected config, and then warns about any remaining local references to the old Worker name.' + ], + arguments: [ + entry('', 'Current Worker name'), + entry('--to ', 'Required new Worker name') + ], + options: [ + entry('--config ', 'Choose the config file to update when multiple configs may match'), + entry('--account ', 'Use a specific Cloudflare account for the remote rename') + ], + examples: [ + entry('devflare worker rename docs --to devflare-docs', 'Rename the Worker and sync the selected config') + ] + }, + { + path: ['tokens'], + summary: 'Manage Devflare-managed Cloudflare API tokens', + usage: [ + 'devflare tokens --list [--account ]', + 'devflare tokens --new [name] [--account ] [--all-flags]', + 'devflare tokens --roll [name] [--account ]', + 'devflare tokens --delete [name] [--account ]', + 'devflare tokens --delete-all [--account ]' + ], + description: [ + 'Creates, lists, rolls, and deletes Devflare-managed account-owned API tokens using a bootstrap token that already has token-management permissions.', + 'Token names are normalized to the `devflare-` prefix automatically.' + ], + arguments: [ + entry('', 'Account-owned bootstrap token with Cloudflare API token-management permissions') + ], + options: [ + entry('--list', 'List Devflare-managed account-owned tokens'), + entry('--new [name]', 'Create a Devflare-managed account-owned token'), + entry('--roll [name]', 'Roll a Devflare-managed token secret'), + entry('--delete [name]', 'Delete a Devflare-managed token by name'), + entry('--delete-all', 'Delete every Devflare-managed token in the selected account'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--all-flags', 'With `--new`, include every reusable account/zone-scoped permission group') + ], + examples: [ + entry('devflare tokens $BOOTSTRAP --list', 'List managed tokens'), + entry('devflare tokens $BOOTSTRAP --new preview', 'Create a managed token named `devflare-preview`'), + entry('devflare tokens $BOOTSTRAP --roll preview', 'Roll the secret for `devflare-preview`'), + entry('devflare tokens $BOOTSTRAP --delete-all', 'Delete every Devflare-managed token for the selected account') + ], + notes: [ + 'Created tokens include the selected account resource and all zones in that account so deploys can manage Worker routes and custom-domain route state.', + 'Cloudflare only returns token secrets once for create and roll operations, so store them immediately.' + ] + }, + { + path: ['ai'], + summary: 'Show Workers AI pricing information', + usage: [ + 'devflare ai' + ], + description: [ + 'Prints the built-in Workers AI pricing reference bundled with Devflare.' + ], + examples: [ + entry('devflare ai', 'Print the bundled Workers AI pricing reference') + ], + notes: [ + 'This command does not currently query live account state; it prints the pricing table bundled with the current Devflare build.' + ] + }, + { + path: ['remote'], + summary: 'Manage remote test mode for paid Cloudflare features', + usage: [ + 'devflare remote [status]', + 'devflare remote enable [minutes]', + 'devflare remote disable' + ], + description: [ + 'Remote mode enables tests that hit real Cloudflare infrastructure for services such as AI and Vectorize.', + 'The default action is `status`.' + ], + subcommands: [ + entry('status', 'Show the current effective remote-mode status'), + entry('enable [minutes]', 'Enable remote mode for a bounded duration (defaults to 30 minutes)'), + entry('disable', 'Disable remote mode immediately') + ], + examples: [ + entry('devflare remote', 'Show the current remote-mode status'), + entry('devflare remote enable 30', 'Enable remote mode for 30 minutes'), + entry('devflare remote disable', 'Disable remote mode') + ], + notes: [ + 'Remote tests use real Cloudflare services and may incur costs.', + '`DEVFLARE_REMOTE` can keep remote mode active even after you run `disable`.' + ] + }, + createRemoteSubcommandPage( + 'status', + 'Show the current effective remote-mode status', + [ + 'devflare remote status' + ], + [ + 'Shows whether remote mode is active, where that state came from, and when it expires if it is time-limited.' + ], + [ + entry('devflare remote status', 'Inspect the current remote-mode status') + ], + [ + '`devflare remote` without a subcommand behaves the same way.' + ] + ), + createRemoteSubcommandPage( + 'enable', + 'Enable remote test mode', + [ + 'devflare remote enable [minutes]' + ], + [ + 'Enables remote test mode for the given duration, defaulting to 30 minutes when the duration is omitted or invalid.' + ], + [ + entry('devflare remote enable', 'Enable remote mode for the default 30 minutes'), + entry('devflare remote enable 90', 'Enable remote mode for 90 minutes') + ], + [ + 'Remote tests can incur real Cloudflare costs, so prefer the shortest useful duration.' + ] + ), + createRemoteSubcommandPage( + 'disable', + 'Disable remote test mode', + [ + 'devflare remote disable' + ], + [ + 'Disables the stored remote-mode window immediately.' + ], + [ + entry('devflare remote disable', 'Disable remote mode immediately') + ], + [ + 'If `DEVFLARE_REMOTE` is still set in the environment, effective remote mode may remain active until you unset it.' + ] + ) +] diff --git a/packages/devflare/src/cli/help-pages/pages/previews.ts b/packages/devflare/src/cli/help-pages/pages/previews.ts new file mode 100644 index 0000000..e73dd4d --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/previews.ts @@ -0,0 +1,132 @@ +import type { HelpPage } from '../types' +import { + PREVIEWS_COMMON_OPTIONS, + createPreviewSubcommandPage, + entry +} from '../shared' + +export const PREVIEW_HELP_PAGES: HelpPage[] = [ + { + path: ['previews'], + summary: 'Inspect and clean dedicated preview Worker scopes', + usage: [ + 'devflare previews [--config ] [--env ] [--account ]', + 'devflare previews list [--config ] [--env ] [--account ]', + 'devflare previews bindings [--config ] [--env ] [--scope ] [--account ] [--worker ]', + 'devflare previews cleanup [--config ] [--env ] [--scope | --all] [--account ] [--apply]' + ], + description: [ + 'The default view resolves the current worker family from local config, or scans child `devflare.config.*` files when you run it from a monorepo root, then inspects live Cloudflare Workers and groups dedicated preview Worker names into preview scopes such as `next` or `pr-42`.', + 'Use `bindings` to inspect preview-scoped resource associations for one scope, or `cleanup` to delete dedicated preview Workers plus preview-only Cloudflare resources for one scope or every live scope Devflare can discover from Worker names.' + ], + subcommands: [ + entry('list', 'List stable workers plus dedicated preview scopes for the current worker family (default)'), + entry('bindings', 'Inspect resolved bindings/resources and how many deployed workers currently reference them'), + entry('cleanup', 'Delete preview-only Worker scripts and preview-scoped Cloudflare resources') + ], + options: [ + ...PREVIEWS_COMMON_OPTIONS, + entry('--config ', 'Use a specific devflare config file for config-aware preview commands'), + entry('--env ', 'Resolve a non-default `config.env[name]` before config-aware preview commands when your preview bindings live outside `env.preview`'), + entry('--scope ', 'Resolve preview-scoped names for a specific identifier on config-aware preview commands'), + entry('--all', 'Clean every live preview scope Devflare can discover for the current worker family when used with `cleanup`'), + entry('--apply', 'Execute cleanup instead of doing a dry run'), + entry('--worker ', 'Override the primary worker name shown in the `bindings` report header') + ], + examples: [ + entry('devflare previews', 'List preview scopes for the current package'), + entry('devflare previews --account ', 'List preview scopes for every configured package when run from a monorepo root'), + entry('devflare previews bindings --scope next', 'Inspect the `next` preview scope and its live worker associations'), + entry('devflare previews cleanup --scope next --apply', 'Delete preview-only resources and dedicated Workers for the `next` scope'), + entry('devflare previews cleanup --all --apply', 'Delete preview-only resources and dedicated Workers for every live discovered preview scope') + ], + notes: [ + 'The default `list` view can aggregate every configured package from a monorepo root. `bindings` and `cleanup` still need one configured package, so run them inside that package or pass `--config `.', + '`bindings` and `cleanup` default to preview-oriented config resolution already, so `--env preview` is usually redundant unless your project stores preview bindings under a different env key.', + '`cleanup` removes preview-only Cloudflare resources for the targeted scope and also deletes dedicated preview-scope Worker scripts when that scope is deployed as its own Worker family. Service bindings, Durable Object bindings, and routes attached only to those dedicated preview Workers disappear with them.', + '`cleanup --all` only targets live preview scopes Devflare can discover from Worker names. Use `--scope ` when you need to clean one scope explicitly, even if its dedicated preview Workers are already gone.', + 'Stable shared Workers are never deleted by `cleanup`.' + ] + }, + createPreviewSubcommandPage( + 'list', + 'List stable workers and dedicated preview scopes', + [ + 'devflare previews [--config ] [--env ] [--account ]', + 'devflare previews list [--config ] [--env ] [--account ]' + ], + [ + 'Resolves the current worker family from local config, or scans child `devflare.config.*` files when invoked from a monorepo root, then queries live Cloudflare Workers and groups dedicated preview Worker names into preview scopes.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` before discovering the worker family'), + entry('--account ', 'Use a specific Cloudflare account') + ], + [ + entry('devflare previews', 'List stable workers and active preview scopes for the current package'), + entry('devflare previews --account ', 'Aggregate stable workers and preview scopes across every configured package in the current workspace root'), + entry('devflare previews list --env preview', 'List preview scopes using a non-default config environment when needed') + ], + [ + '`list` is the default subcommand, so `devflare previews` and `devflare previews list` show the same view.', + 'When more than one config is discovered, Devflare prints one worker-family block per configured package.' + ] + ), + createPreviewSubcommandPage( + 'bindings', + 'Inspect resolved bindings/resources and live worker associations', + [ + 'devflare previews bindings [--config ] [--env ] [--scope ] [--account ] [--worker ]' + ], + [ + 'Resolves the current config for one preview scope, inspects live worker deployments, and reports how many deployed workers reference each resource or binding target.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` before inspecting bindings'), + entry('--scope ', 'Resolve preview-scoped names for a specific identifier instead of the default `preview` scope'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Override the primary worker name shown in the report header') + ], + [ + entry('devflare previews bindings --scope next', 'Inspect preview-scoped bindings for the `next` scope'), + entry('devflare previews bindings', 'Inspect preview-scoped bindings for the default `preview` scope') + ], + [ + 'This command is read-only; it does not delete Workers or resources.', + 'Omit `--env preview` unless your project stores preview bindings under a different env key.' + ] + ), + createPreviewSubcommandPage( + 'cleanup', + 'Delete preview-only Worker scripts and preview-scoped Cloudflare resources', + [ + 'devflare previews cleanup [--config ] [--env ] [--scope | --all] [--account ] [--apply]' + ], + [ + 'Resolves preview-scoped resource names from the current config, deletes dedicated preview Worker scripts for the targeted scope when they exist, and removes matching preview-only Cloudflare resources from the selected account. Preview-only service bindings, Durable Object bindings, and routes attached exclusively to those dedicated Workers disappear with them.', + 'Use `--scope ` for one preview scope or `--all` to iterate every live preview scope Devflare can discover for the current worker family.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve a non-default `config.env[name]` before cleanup when your preview bindings live outside `env.preview`'), + entry('--scope ', 'Clean one preview scope instead of the default `preview` scope'), + entry('--all', 'Clean every live preview scope Devflare can discover for the current worker family'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--apply', 'Apply the cleanup instead of doing a dry run') + ], + [ + entry('devflare previews cleanup --scope next', 'Show which dedicated Workers and preview-only resources belong to the `next` scope'), + entry('devflare previews cleanup --all', 'Show the cleanup plan for every live discovered preview scope'), + entry('devflare previews cleanup --all --apply', 'Delete dedicated preview Workers and preview-only resources for every live discovered preview scope') + ], + [ + 'Dedicated preview Worker scripts are candidates only when their names resolve to the targeted preview scope. Stable shared Workers are never deleted.', + 'Without `--scope`, the command defaults to the `preview` scope. Use `--all` when you want every live preview scope Devflare can discover instead of just that default.', + 'Deleting dedicated preview Worker scripts removes preview-only service bindings, Durable Object bindings, and routes owned solely by those Workers.', + 'Omit `--env preview` unless your config stores preview bindings under a different env key.', + 'Analytics Engine datasets and Browser Rendering bindings are intentionally reported as warnings instead of deleted resources.' + ] + ) +] diff --git a/packages/devflare/src/cli/help-pages/pages/productions.ts b/packages/devflare/src/cli/help-pages/pages/productions.ts new file mode 100644 index 0000000..0b0bb7f --- /dev/null +++ b/packages/devflare/src/cli/help-pages/pages/productions.ts @@ -0,0 +1,144 @@ +import type { HelpPage } from '../types' +import { + createProductionsSubcommandPage, + entry +} from '../shared' + +export const PRODUCTION_HELP_PAGES: HelpPage[] = [ + { + path: ['productions'], + summary: 'Inspect and manage live production Workers and deployments', + usage: [ + 'devflare productions [--config ] [--env ] [--account ] [--worker ]', + 'devflare productions versions [--config ] [--env ] [--account ] [--worker ]', + 'devflare productions rollback [--config ] [--account ] [--worker ] [--version-id ] [--message ] [--apply]', + 'devflare productions delete [--config ] [--account ] [--worker ] [--apply]' + ], + description: [ + 'The default view inspects live Cloudflare production deployment state for locally configured Workers, or for one explicitly selected Worker.', + 'Other subcommands list recent production versions or mutate a single Worker by rolling back or deleting its live production script.' + ], + subcommands: [ + entry('list', 'List live production Workers and their active deployments (default)'), + entry('versions', 'Show recent stored production versions and which one is currently active'), + entry('rollback', 'Roll a Worker back to the previous or specified production version'), + entry('delete', 'Delete a live production Worker script') + ], + options: [ + entry('--config ', 'Use a specific devflare config file or scan the current tree when omitted'), + entry('--env ', 'Resolve `config.env[name]` while discovering related production Workers (defaults to `production`)'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Target a specific Worker instead of the locally configured set'), + entry('--version ', 'Version selector shortcut for `rollback` (same as --version-id)'), + entry('--version-id ', 'Target a specific production version when rolling back'), + entry('--message ', 'Attach a rollback message when using `rollback`'), + entry('--apply', 'Execute rollback or delete instead of doing a dry run') + ], + examples: [ + entry('devflare productions', 'Inspect active production deployments for the current package or monorepo tree'), + entry('devflare productions versions', 'Show recent production versions for the resolved Workers'), + entry('devflare productions rollback --worker my-worker --apply', 'Roll `my-worker` back to the previous production version'), + entry('devflare productions rollback --worker my-worker --version-id 1234abcd-... --apply', 'Roll `my-worker` back to a specific production version'), + entry('devflare productions delete --worker my-worker --apply', 'Delete the live production Worker script for `my-worker`') + ], + notes: [ + '`productions` reads live Cloudflare control-plane state. It does not depend on the Devflare preview registry database.', + '`rollback` uses Wrangler under the hood because Cloudflare exposes production rollback through the Wrangler deployment flow.', + '`delete` removes the Worker script only. Review KV, D1, R2, queues, and other account resources separately before cleaning them up.' + ] + }, + createProductionsSubcommandPage( + 'list', + 'List live production Workers and their active deployments', + [ + 'devflare productions [--config ] [--env ] [--account ] [--worker ]' + ], + [ + 'Inspects live Cloudflare production deployment state for locally configured Workers, or for one explicitly selected Worker when `--worker` is provided.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` while discovering related production Workers (defaults to `production`)'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Target a specific Worker instead of the locally configured set') + ], + [ + entry('devflare productions', 'Inspect the current package or monorepo production Workers'), + entry('devflare productions --worker my-worker', 'Inspect one live production Worker directly') + ], + [ + 'This view is read-only and is backed by live Cloudflare production deployment data.' + ] + ), + createProductionsSubcommandPage( + 'versions', + 'Show recent stored production versions and the current active version', + [ + 'devflare productions versions [--config ] [--env ] [--account ] [--worker ]' + ], + [ + 'Lists recent stored production versions for the selected Worker set and marks the version currently active in the latest production deployment.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--env ', 'Resolve `config.env[name]` while discovering related production Workers (defaults to `production`)'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Target a specific Worker instead of the locally configured set') + ], + [ + entry('devflare productions versions', 'Show recent stored production versions for the resolved Workers'), + entry('devflare productions versions --worker my-worker', 'Show recent stored production versions for `my-worker`') + ] + ), + createProductionsSubcommandPage( + 'rollback', + 'Roll a Worker back to the previous or specified production version', + [ + 'devflare productions rollback [--config ] [--account ] [--worker ] [--version-id ] [--message ] [--apply]' + ], + [ + 'Uses Wrangler rollback to create a fresh production deployment that points at the previous or explicitly selected version.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Worker to roll back (required unless the current package resolves to exactly one primary Worker)'), + entry('--version ', 'Version selector shortcut for `rollback` (same as --version-id)'), + entry('--version-id ', 'Roll back to a specific production version instead of the previous one'), + entry('--message ', 'Attach an explicit rollback message'), + entry('--apply', 'Apply the rollback instead of doing a dry run') + ], + [ + entry('devflare productions rollback --worker my-worker', 'Preview a rollback for `my-worker`'), + entry('devflare productions rollback --worker my-worker --apply', 'Roll `my-worker` back to the previous production version'), + entry('devflare productions rollback --worker my-worker --version-id 1234abcd-... --apply', 'Roll `my-worker` back to a specific production version') + ], + [ + 'Without `--apply`, this command is a dry run.' + ] + ), + createProductionsSubcommandPage( + 'delete', + 'Delete a live production Worker script', + [ + 'devflare productions delete [--config ] [--account ] [--worker ] [--apply]' + ], + [ + 'Deletes the selected live production Worker script from Cloudflare. This does not automatically remove independent account resources such as KV namespaces or D1 databases.' + ], + [ + entry('--config ', 'Use a specific devflare config file'), + entry('--account ', 'Use a specific Cloudflare account'), + entry('--worker ', 'Worker to delete (required unless the current package resolves to exactly one primary Worker)'), + entry('--apply', 'Apply the deletion instead of doing a dry run') + ], + [ + entry('devflare productions delete --worker my-worker', 'Preview deletion of `my-worker`'), + entry('devflare productions delete --worker my-worker --apply', 'Delete the live production Worker script for `my-worker`') + ], + [ + 'Without `--apply`, this command is a dry run.', + 'Deleting the Worker script does not clean up shared Cloudflare account resources automatically.' + ] + ) +] diff --git a/packages/devflare/src/cli/help-pages/render.ts b/packages/devflare/src/cli/help-pages/render.ts new file mode 100644 index 0000000..8ce5eb3 --- /dev/null +++ b/packages/devflare/src/cli/help-pages/render.ts @@ -0,0 +1,111 @@ +import { accent, bold, createCliTheme, cyan, cyanBold, dim, formatBullet, formatCommand, type CliTheme } from '../ui' +import { COMMAND_ALIASES, COMMANDS, type Command } from './shared' +import type { HelpEntry, HelpPage } from './types' + +export function createHelpPageMap(pages: HelpPage[]): Map { + return new Map(pages.map((page) => [page.path.join(' '), page])) +} + +export function canonicalizeHelpPath(path: string[]): string[] { + const trimmedPath = path + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + + if (trimmedPath.length === 0) { + return [] + } + + const [first, ...rest] = trimmedPath + return [COMMAND_ALIASES[first] ?? first, ...rest] +} + +export function resolveHelpPage( + path: string[], + helpPageMap: Map +): HelpPage | undefined { + const canonicalPath = canonicalizeHelpPath(path) + if (canonicalPath.length === 0) { + return helpPageMap.get('') + } + + if (!COMMANDS.includes(canonicalPath[0] as Command) && !(canonicalPath[0] in COMMAND_ALIASES)) { + return undefined + } + + for (let length = canonicalPath.length;length > 0;length--) { + const key = canonicalPath.slice(0, length).join(' ') + const page = helpPageMap.get(key) + if (page) { + return page + } + } + + return undefined +} + +function appendSection(lines: string[], title: string, sectionLines: string[]): void { + if (sectionLines.length === 0) { + return + } + + if (lines.length > 0 && lines[lines.length - 1] !== '') { + lines.push('') + } + + lines.push(title) + lines.push(...sectionLines) +} + +function renderEntryList(entries: HelpEntry[], theme: CliTheme): string[] { + return entries.map((item) => formatCommand(item.command, item.description, theme)) +} + +function renderBulletList(items: string[], theme: CliTheme): string[] { + return items.map((item) => formatBullet(item, theme)) +} + +export function renderHelpPage(page: HelpPage, theme: CliTheme): string { + const commandLabel = page.path.length === 0 ? 'devflare' : `devflare ${page.path.join(' ')}` + const lines: string[] = [ + '', + `${cyanBold(commandLabel, theme)} ${dim(page.summary, theme)}`, + '' + ] + + appendSection( + lines, + dim('usage', theme), + page.usage.map((usageLine) => ` ${cyan(usageLine, theme)}`) + ) + + appendSection(lines, dim('overview', theme), renderBulletList(page.description ?? [], theme)) + appendSection(lines, dim('arguments', theme), renderEntryList(page.arguments ?? [], theme)) + appendSection(lines, dim('subcommands', theme), renderEntryList(page.subcommands ?? [], theme)) + appendSection(lines, dim(page.optionSectionTitle ?? 'options', theme), renderEntryList(page.options ?? [], theme)) + + if (page.aliases && page.aliases.length > 0) { + appendSection( + lines, + dim('aliases', theme), + page.aliases.map((alias) => ` ${accent(alias, theme, 'green')}`) + ) + } + + appendSection(lines, dim('examples', theme), renderEntryList(page.examples ?? [], theme)) + appendSection(lines, dim('notes', theme), renderBulletList(page.notes ?? [], theme)) + lines.push('') + + return lines.join('\n') +} + +export function createThemes(options: Record = {}): { + styled: CliTheme + plain: CliTheme +} { + return { + styled: createCliTheme(options), + plain: { useColor: false } + } +} + +export { bold } diff --git a/packages/devflare/src/cli/help-pages/shared.ts b/packages/devflare/src/cli/help-pages/shared.ts new file mode 100644 index 0000000..8a2d573 --- /dev/null +++ b/packages/devflare/src/cli/help-pages/shared.ts @@ -0,0 +1,121 @@ +import type { HelpEntry, HelpPage } from './types' + +export const COMMANDS = ['init', 'dev', 'build', 'deploy', 'types', 'doctor', 'config', 'account', 'login', 'previews', 'productions', 'worker', 'tokens', 'secrets', 'ai', 'remote', 'help', 'version'] as const +export type Command = typeof COMMANDS[number] + +export const COMMAND_ALIASES: Record = {} + +export const COMMON_OPTIONS: HelpEntry[] = [ + { command: '--config ', description: 'Select a specific devflare config file when the command supports it' }, + { command: '--env ', description: 'Resolve config.env[name] when the command supports environments' }, + { command: '--debug', description: 'Print extra stack traces and debug output for supported commands' }, + { command: '--no-color', description: 'Disable ANSI color output' }, + { command: '-h, --help', description: 'Show detailed help for the current command' }, + { command: '-v, --version', description: 'Show the installed devflare version' } +] + +export const ACCOUNT_OPTION: HelpEntry = { + command: '--account ', + description: 'Use a specific Cloudflare account instead of the workspace/global/default account' +} + +export const PREVIEWS_COMMON_OPTIONS: HelpEntry[] = [ + { command: '--account ', description: 'Use a specific Cloudflare account for preview Worker and preview-resource operations' } +] + +export function entry(command: string, description: string): HelpEntry { + return { command, description } +} + +function createSubcommandHelpPage(options: { + parentPath: string + subcommand: string + summary: string + usage: string[] + description: string[] + options?: HelpEntry[] + examples?: HelpEntry[] + notes?: string[] +}): HelpPage { + return { + path: [options.parentPath, options.subcommand], + summary: options.summary, + usage: options.usage, + description: options.description, + ...(options.options !== undefined ? { options: options.options } : {}), + ...(options.examples !== undefined ? { examples: options.examples } : {}), + ...(options.notes !== undefined ? { notes: options.notes } : {}) + } +} + +type OptionedSubcommandHelpPageFactory = ( + subcommand: string, + summary: string, + usage: string[], + description: string[], + options: HelpEntry[], + examples: HelpEntry[], + notes?: string[] +) => HelpPage + +function createOptionedSubcommandHelpPageFactory(parentPath: string): OptionedSubcommandHelpPageFactory { + return (subcommand, summary, usage, description, options, examples, notes = []) => { + return createSubcommandHelpPage({ + parentPath, + subcommand, + summary, + usage, + description, + options, + examples, + notes + }) + } +} + +export function createAccountInventoryPage( + subcommand: string, + summary: string, + description: string, + exampleDescription: string +): HelpPage { + return createSubcommandHelpPage({ + parentPath: 'account', + subcommand, + summary, + usage: [ + `devflare account ${subcommand} [--account ]` + ], + description: [description], + options: [ACCOUNT_OPTION], + examples: [ + entry(`devflare account ${subcommand}`, exampleDescription) + ], + notes: [ + 'Omit --account to resolve the account from workspace preferences, CLOUDFLARE_ACCOUNT_ID, devflare.config.*, or the primary authenticated account.' + ] + }) +} + +export function createRemoteSubcommandPage( + subcommand: string, + summary: string, + usage: string[], + description: string[], + examples: HelpEntry[], + notes: string[] +): HelpPage { + return createSubcommandHelpPage({ + parentPath: 'remote', + subcommand, + summary, + usage, + description, + examples, + notes + }) +} + +export const createPreviewSubcommandPage = createOptionedSubcommandHelpPageFactory('previews') + +export const createProductionsSubcommandPage = createOptionedSubcommandHelpPageFactory('productions') diff --git a/packages/devflare/src/cli/help-pages/types.ts b/packages/devflare/src/cli/help-pages/types.ts new file mode 100644 index 0000000..842f02f --- /dev/null +++ b/packages/devflare/src/cli/help-pages/types.ts @@ -0,0 +1,24 @@ +export interface HelpEntry { + command: string + description: string +} + +export interface HelpPage { + path: string[] + summary: string + usage: string[] + description?: string[] + arguments?: HelpEntry[] + options?: HelpEntry[] + subcommands?: HelpEntry[] + examples?: HelpEntry[] + notes?: string[] + aliases?: string[] + optionSectionTitle?: string +} + +export interface RenderedHelp { + styled: string + plain: string + path: string[] +} diff --git a/packages/devflare/src/cli/help.ts b/packages/devflare/src/cli/help.ts new file mode 100644 index 0000000..2105364 --- /dev/null +++ b/packages/devflare/src/cli/help.ts @@ -0,0 +1,24 @@ +import { createHelpPageMap, createThemes, renderHelpPage, resolveHelpPage } from './help-pages/render' +import { HELP_PAGES } from './help-pages/pages' +import { COMMANDS, type Command } from './help-pages/shared' +import type { RenderedHelp } from './help-pages/types' + +export { COMMANDS } +export type { Command } + +const HELP_PAGE_MAP = createHelpPageMap(HELP_PAGES) + +export function renderHelp(path: string[], options: Record = {}): RenderedHelp | undefined { + const page = resolveHelpPage(path, HELP_PAGE_MAP) + if (!page) { + return undefined + } + + const themes = createThemes(options) + + return { + styled: renderHelpPage(page, themes.styled), + plain: renderHelpPage(page, themes.plain), + path: page.path + } +} diff --git a/packages/devflare/src/cli/index.ts b/packages/devflare/src/cli/index.ts new file mode 100644 index 0000000..ad25b10 --- /dev/null +++ b/packages/devflare/src/cli/index.ts @@ -0,0 +1,376 @@ +// ============================================================================= +// CLI Entry Point โ€” Command parsing and routing +// ============================================================================= + +import { createConsola, type ConsolaInstance } from 'consola' +import { getPackageVersion } from './package-metadata' +import { COMMANDS, renderHelp, type Command } from './help' +import { getLocalWorkspaceBuildGuardMessage } from './workspace-build-guard' +import { createCliTheme, cyanBold, dim, logLine } from './ui' + +// ============================================================================= +// Types +// ============================================================================= + +export interface ParsedArgs { + command: string + args: string[] + options: Record + unknownCommand?: string +} + +export interface CliOptions { + silent?: boolean + cwd?: string + requireExplicitDeployTarget?: boolean +} + +export interface CliResult { + exitCode: number + output?: string +} + +// ============================================================================= +// Constants +// ============================================================================= + +// ============================================================================= +// Argument Parser +// ============================================================================= + +/** + * Parses CLI arguments into structured format + */ +export function parseArgs(argv: string[]): ParsedArgs { + const args: string[] = [] + const options: Record = {} + let command: string | undefined + let unknownCommand: string | undefined + + let i = 0 + const shortOptionAliases: Record = { + h: 'help', + v: 'version' + } + + while (i < argv.length) { + const arg = argv[i] + + if (arg.startsWith('-') && !/^-\d/.test(arg)) { + // Parse option (but not negative numbers like -5) + const isLongFlag = arg.startsWith('--') + const rawKey = isLongFlag ? arg.slice(2) : arg.slice(1) + const key = isLongFlag ? rawKey : (shortOptionAliases[rawKey] ?? rawKey) + + // Check if next arg is a value (doesn't start with -) + const nextArg = argv[i + 1] + if (nextArg && !nextArg.startsWith('-')) { + options[key] = nextArg + i += 2 + } else { + options[key] = true + i++ + } + } else if (!command) { + // First non-flag arg is the command + if (COMMANDS.includes(arg as Command)) { + command = arg + } else { + unknownCommand = arg + break + } + i++ + } else { + // Positional argument + args.push(arg) + i++ + } + } + + if (unknownCommand) { + return { + command: 'help', + args: [], + options, + unknownCommand + } + } + + if (!command) { + if (options.version === true) { + return { command: 'version', args: [], options } + } + + return { command: 'help', args: [], options } + } + + return { command, args, options, unknownCommand } +} + +// ============================================================================= +// CLI Runner +// ============================================================================= + +/** + * Main CLI entry point + */ +export async function runCli( + argv: string[], + options: CliOptions = {} +): Promise { + const logger = createConsola({ + level: options.silent ? -999 : 3, + formatOptions: { + date: false + } + }) + + const parsed = parseArgs(argv) + const theme = createCliTheme(parsed.options) + + // Handle unknown command + if (parsed.unknownCommand) { + logger.error(`Unknown command: ${parsed.unknownCommand}`) + logLine(logger, dim('Run `devflare --help` for available commands', theme)) + return { exitCode: 1 } + } + + const wantsHelp = parsed.options.help === true + const helpPath = parsed.command === 'help' + ? parsed.args + : wantsHelp + ? [parsed.command, ...parsed.args] + : undefined + + if (helpPath) { + const renderedHelp = renderHelp(helpPath, parsed.options) + if (!renderedHelp) { + const requestedTopic = helpPath.join(' ') + logger.error(`Unknown help topic: ${requestedTopic}`) + logLine(logger, dim('Run `devflare --help` for available commands', theme)) + return { exitCode: 1 } + } + + logLine(logger, renderedHelp.styled) + return { exitCode: 0, output: renderedHelp.plain } + } + + const workspaceBuildGuardMessage = await getLocalWorkspaceBuildGuardMessage(parsed.command) + if (workspaceBuildGuardMessage) { + logger.error(workspaceBuildGuardMessage) + return { exitCode: 1 } + } + + // Route to command handler + switch (parsed.command) { + case 'version': + const version = await getPackageVersion() + logLine(logger, `${cyanBold('devflare', theme)} ${dim(`v${version}`, theme)}`) + return { exitCode: 0, output: version } + + case 'init': + return runInit(parsed, logger, options) + + case 'dev': + return runDev(parsed, logger, options) + + case 'build': + return runBuild(parsed, logger, options) + + case 'deploy': + return runDeploy(parsed, logger, options) + + case 'types': + return runTypes(parsed, logger, options) + + case 'doctor': + return runDoctor(parsed, logger, options) + + case 'config': + return runConfig(parsed, logger, options) + + case 'account': + return runAccount(parsed, logger, options) + + case 'login': + return runLogin(parsed, logger, options) + + case 'previews': + return runPreviews(parsed, logger, options) + + case 'productions': + return runProductions(parsed, logger, options) + + case 'worker': + return runWorker(parsed, logger, options) + + case 'tokens': + return runToken(parsed, logger, options) + + case 'secrets': + return runSecrets(parsed, logger, options) + + case 'ai': + return runAI() + + case 'remote': + return runRemote(parsed, logger, options) + + default: + logger.error(`Unknown command: ${parsed.command}`) + return { exitCode: 1 } + } +} + +// ============================================================================= +// Command Stubs (to be implemented) +// ============================================================================= + +async function runInit( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in init.ts + const { runInitCommand } = await import('./commands/init') + return runInitCommand(parsed, logger, options) +} + +async function runDev( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in dev.ts + const { runDevCommand } = await import('./commands/dev') + return runDevCommand(parsed, logger, options) +} + +async function runBuild( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in build.ts + const { runBuildCommand } = await import('./commands/build') + return runBuildCommand(parsed, logger, options) +} + +async function runDeploy( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in deploy.ts + const { runDeployCommand } = await import('./commands/deploy') + return runDeployCommand(parsed, logger, { + ...options, + requireExplicitDeployTarget: true + }) +} + +async function runTypes( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in types.ts + const { runTypesCommand } = await import('./commands/types') + return runTypesCommand(parsed, logger, options) +} + +async function runDoctor( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + // Will be implemented in doctor.ts + const { runDoctorCommand } = await import('./commands/doctor') + return runDoctorCommand(parsed, logger, options) +} + +async function runConfig( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runConfigCommand } = await import('./commands/config') + return runConfigCommand(parsed, logger, options) +} + +async function runAccount( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runAccountCommand } = await import('./commands/account') + return runAccountCommand(parsed, logger, options) +} + +async function runLogin( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runLoginCommand } = await import('./commands/login') + return runLoginCommand(parsed, logger, options) +} + +async function runPreviews( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runPreviewsCommand } = await import('./commands/previews') + return runPreviewsCommand(parsed, logger, options) +} + +async function runProductions( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runProductionsCommand } = await import('./commands/productions') + return runProductionsCommand(parsed, logger, options) +} + +async function runWorker( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runWorkerCommand } = await import('./commands/worker') + return runWorkerCommand(parsed, logger, options) +} + +async function runToken( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runTokenCommand } = await import('./commands/token') + return runTokenCommand(parsed, logger, options) +} + +async function runSecrets( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runSecretsCommand } = await import('./commands/secrets') + return runSecretsCommand(parsed, logger, options) +} + +async function runAI(): Promise { + const { runAICommand } = await import('./commands/ai') + return runAICommand() +} + +async function runRemote( + parsed: ParsedArgs, + logger: ConsolaInstance, + options: CliOptions +): Promise { + const { runRemoteCommand } = await import('./commands/remote') + return runRemoteCommand(parsed, logger, options) +} diff --git a/packages/devflare/src/cli/package-metadata.ts b/packages/devflare/src/cli/package-metadata.ts new file mode 100644 index 0000000..33b4a71 --- /dev/null +++ b/packages/devflare/src/cli/package-metadata.ts @@ -0,0 +1,69 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'pathe' + +interface PackageJsonMetadata { + version?: string + dependencies?: Record + devDependencies?: Record +} + +export interface InitDependencyVersions { + devflare: string + typescript: string + wrangler: string + workersTypes: string +} + +let packageMetadataPromise: Promise | null = null + +async function loadPackageMetadata(): Promise { + let currentDir = dirname(fileURLToPath(import.meta.url)) + + while (true) { + const packageJsonPath = resolve(currentDir, 'package.json') + + try { + const packageJson = await readFile(packageJsonPath, 'utf8') + const metadata = JSON.parse(packageJson) as PackageJsonMetadata & { name?: string } + + if (metadata.name === 'devflare') { + return metadata + } + } catch { + // Keep walking upward until we find the published package root + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) { + throw new Error('Could not resolve the devflare package.json file') + } + + currentDir = parentDir + } +} + +export async function getPackageMetadata(): Promise { + if (!packageMetadataPromise) { + packageMetadataPromise = loadPackageMetadata() + } + + return packageMetadataPromise +} + +export async function getPackageVersion(): Promise { + return (await getPackageMetadata()).version ?? '0.0.0' +} + +export async function getInitDependencyVersions(): Promise { + const metadata = await getPackageMetadata() + const dependencies = metadata.dependencies ?? {} + const devDependencies = metadata.devDependencies ?? {} + + return { + devflare: `^${metadata.version ?? '0.0.0'}`, + typescript: devDependencies.typescript ?? '^5.7.0', + wrangler: dependencies.wrangler ?? devDependencies.wrangler ?? '^4.85.0', + workersTypes: devDependencies['@cloudflare/workers-types'] ?? '^4.20250109.0' + } +} diff --git a/packages/devflare/src/cli/preview-bindings.ts b/packages/devflare/src/cli/preview-bindings.ts new file mode 100644 index 0000000..ee3d5a3 --- /dev/null +++ b/packages/devflare/src/cli/preview-bindings.ts @@ -0,0 +1,775 @@ +import { account, type APIClientOptions, type WorkerDeploymentInfo } from '../cloudflare' +import { compileBuildConfig } from '../config/compiler' +import type { DevflareConfig } from '../config/schema' +import type { ProcessRunner } from './dependencies' + +export interface ParsedWranglerBindingRow { + type: string + bindingName: string + resource: string +} + +export interface ParsedQueueAssociation { + queueName: string + producerWorkers: string[] + consumerWorkers: string[] +} + +interface BindingAssociationTarget { + key: string + referenceLabels: string[] + type: string + resource: string + notes: string[] + queueName?: string +} + +export interface BindingAssociationRow { + reference: string + type: string + resource: string + workerCount: number + connectedWorkers: string[] + notes: string[] + producerWorkers?: string[] + consumerWorkers?: string[] +} + +export interface BindingAssociationInspection { + workerName: string + rows: BindingAssociationRow[] + targets: number + scannedWorkers: string[] + warnings: string[] +} + +export interface InspectBindingAssociationsOptions { + accountId: string + config: DevflareConfig + workerName?: string + cwd: string + exec: ProcessRunner + apiOptions?: APIClientOptions +} + +function normalizeCell(value: string | undefined): string { + return (value ?? '').trim().replace(/\s+/g, ' ') +} + +function buildAssociationKey(type: string, resource: string): string { + return `${normalizeCell(type).toLowerCase()}\u0000${normalizeCell(resource).toLowerCase()}` +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.filter((value) => value.trim().length > 0))) +} + +function formatSendEmailResource(entry: { + destination_address?: string + allowed_destination_addresses?: string[] + allowed_sender_addresses?: string[] +}): string { + const destination = entry.destination_address?.trim() + if (destination) { + return destination + } + + const destinations = uniqueStrings(entry.allowed_destination_addresses ?? []) + const senders = uniqueStrings(entry.allowed_sender_addresses ?? []) + const destinationLabel = destinations.length > 0 ? destinations.join(', ') : 'configured destinations' + + if (senders.length === 0) { + return destinationLabel + } + + return `${destinationLabel} - senders: ${senders.join(', ')}` +} + +function addAssociationTarget( + targets: Map, + input: { + reference?: string + type: string + resource?: string + note?: string + queueName?: string + } +): void { + const type = normalizeCell(input.type) + const resource = normalizeCell(input.resource) + const key = buildAssociationKey(type, resource) + const existing = targets.get(key) + + if (existing) { + if (input.reference) { + existing.referenceLabels = uniqueStrings([...existing.referenceLabels, input.reference]) + } + if (input.note) { + existing.notes = uniqueStrings([...existing.notes, input.note]) + } + if (!existing.queueName && input.queueName) { + existing.queueName = input.queueName + } + return + } + + targets.set(key, { + key, + referenceLabels: input.reference ? [input.reference] : [], + type, + resource, + notes: input.note ? [input.note] : [], + queueName: input.queueName + }) +} + +function collectBindingAssociationTargets(config: DevflareConfig): BindingAssociationTarget[] { + const compiled = compileBuildConfig(config) + const targets = new Map() + + for (const binding of compiled.kv_namespaces ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'KV Namespace', + resource: 'id' in binding ? binding.id : binding.name + }) + } + + for (const binding of compiled.d1_databases ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'D1 Database', + resource: 'database_id' in binding ? binding.database_id : binding.database_name + }) + } + + for (const binding of compiled.r2_buckets ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'R2 Bucket', + resource: binding.bucket_name + }) + } + + for (const binding of compiled.durable_objects?.bindings ?? []) { + addAssociationTarget(targets, { + reference: binding.name, + type: 'Durable Object Namespace', + resource: binding.class_name, + note: binding.script_name ? `script ${binding.script_name}` : undefined + }) + } + + for (const binding of compiled.queues?.producers ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Queue', + resource: binding.queue, + note: 'producer binding', + queueName: binding.queue + }) + } + + for (const binding of compiled.queues?.consumers ?? []) { + addAssociationTarget(targets, { + type: 'Queue', + resource: binding.queue, + note: 'consumer attachment', + queueName: binding.queue + }) + + if (binding.dead_letter_queue) { + addAssociationTarget(targets, { + type: 'Queue', + resource: binding.dead_letter_queue, + note: 'dead letter queue', + queueName: binding.dead_letter_queue + }) + } + } + + for (const binding of compiled.ratelimits ?? []) { + addAssociationTarget(targets, { + reference: binding.name, + type: 'Rate Limiting', + resource: binding.namespace_id + }) + } + + if (compiled.version_metadata) { + addAssociationTarget(targets, { + reference: compiled.version_metadata.binding, + type: 'Version Metadata', + resource: 'Version Metadata' + }) + } + + for (const binding of compiled.worker_loaders ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Worker Loader', + resource: 'Worker Loader' + }) + } + + for (const binding of compiled.mtls_certificates ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'mTLS Certificate', + resource: binding.certificate_id + }) + } + + for (const binding of compiled.dispatch_namespaces ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Dispatch Namespace', + resource: binding.namespace, + note: binding.outbound ? `outbound ${binding.outbound.service}` : undefined + }) + } + + for (const binding of compiled.workflows ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Workflow', + resource: binding.name, + note: binding.script_name ? `script ${binding.script_name}` : binding.class_name + }) + } + + for (const binding of compiled.pipelines ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Pipeline', + resource: binding.pipeline, + note: binding.remote ? 'remote local binding' : undefined + }) + } + + if (compiled.images) { + addAssociationTarget(targets, { + reference: compiled.images.binding, + type: 'Images', + resource: 'Images', + note: compiled.images.remote ? 'remote local binding' : undefined + }) + } + + if (compiled.media) { + addAssociationTarget(targets, { + reference: compiled.media.binding, + type: 'Media Transformations', + resource: 'Media Transformations', + note: compiled.media.remote ? 'remote local binding' : undefined + }) + } + + for (const binding of compiled.artifacts ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Artifacts', + resource: binding.namespace, + note: binding.remote ? 'remote local binding' : undefined + }) + } + + for (const binding of compiled.secrets_store_secrets ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Secrets Store', + resource: `${binding.store_id}/${binding.secret_name}` + }) + } + + for (const consumer of compiled.tail_consumers ?? []) { + addAssociationTarget(targets, { + type: 'Tail Consumer', + resource: consumer.service, + note: consumer.environment ? `env ${consumer.environment}` : undefined + }) + } + + for (const binding of compiled.services ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Worker', + resource: binding.entrypoint + ? `${binding.service}#${binding.entrypoint}` + : binding.service, + note: binding.environment ? `env ${binding.environment}` : undefined + }) + } + + if (compiled.ai?.binding) { + addAssociationTarget(targets, { + reference: compiled.ai.binding, + type: 'AI', + resource: 'Workers AI' + }) + } + + for (const binding of compiled.vectorize ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Vectorize', + resource: binding.index_name + }) + } + + for (const binding of compiled.hyperdrive ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Hyperdrive', + resource: 'id' in binding ? binding.id : binding.name + }) + } + + if (compiled.browser?.binding) { + addAssociationTarget(targets, { + reference: compiled.browser.binding, + type: 'Browser', + resource: 'Browser Rendering' + }) + } + + for (const binding of compiled.analytics_engine_datasets ?? []) { + addAssociationTarget(targets, { + reference: binding.binding, + type: 'Analytics Engine', + resource: binding.dataset + }) + } + + for (const binding of compiled.send_email ?? []) { + addAssociationTarget(targets, { + reference: binding.name, + type: 'Send Email', + resource: formatSendEmailResource(binding) + }) + } + + return Array.from(targets.values()) +} + +function getActiveVersionId(deployments: WorkerDeploymentInfo[]): string | undefined { + const sortedDeployments = [...deployments].sort((left, right) => right.createdOn.getTime() - left.createdOn.getTime()) + + for (const deployment of sortedDeployments) { + const version = [...deployment.versions].sort((left, right) => right.percentage - left.percentage)[0] + if (version?.versionId) { + return version.versionId + } + } + + return undefined +} + +function extractWorkerNames(value: string): string[] { + return Array.from(value.matchAll(/worker:([^,\s]+)/gi), (match) => match[1]) +} + +export function parseWranglerQueueInfo(output: string): ParsedQueueAssociation | null { + const lines = output.split(/\r?\n/) + let queueName = '' + const producerWorkers: string[] = [] + const consumerWorkers: string[] = [] + let currentSection: 'producers' | 'consumers' | null = null + + for (const rawLine of lines) { + const trimmed = rawLine.trim() + if (!trimmed) { + currentSection = null + continue + } + + const queueMatch = trimmed.match(/^Queue Name:\s*(.+)$/i) + if (queueMatch) { + queueName = normalizeCell(queueMatch[1]) + currentSection = null + continue + } + + const producerMatch = trimmed.match(/^Producers:\s*(.*)$/i) + if (producerMatch) { + producerWorkers.push(...extractWorkerNames(producerMatch[1])) + currentSection = producerMatch[1] ? null : 'producers' + continue + } + + const consumerMatch = trimmed.match(/^Consumers:\s*(.*)$/i) + if (consumerMatch) { + consumerWorkers.push(...extractWorkerNames(consumerMatch[1])) + currentSection = consumerMatch[1] ? null : 'consumers' + continue + } + + if (!currentSection) { + continue + } + + const extractedWorkers = extractWorkerNames(trimmed) + if (currentSection === 'producers') { + producerWorkers.push(...extractedWorkers) + } else { + consumerWorkers.push(...extractedWorkers) + } + } + + if (!queueName) { + return null + } + + return { + queueName, + producerWorkers: uniqueStrings(producerWorkers), + consumerWorkers: uniqueStrings(consumerWorkers) + } +} + +/** + * Parse the JSON output of `wrangler versions view --json` into a flat list + * of `{ type, bindingName, resource }` rows that match the friendly type + * labels used by `collectBindingAssociationTargets`. + * + * Requires Wrangler 3.99+ (the `--json` flag on `versions view`). + */ +export function parseWranglerVersionBindings(jsonOutput: string): ParsedWranglerBindingRow[] { + let parsed: unknown + try { + parsed = JSON.parse(jsonOutput) + } catch { + return [] + } + + const bindings = extractBindingsArray(parsed) + if (!bindings) { + return [] + } + + const rows: ParsedWranglerBindingRow[] = [] + for (const raw of bindings) { + const row = mapWranglerBindingToRow(raw) + if (row) { + rows.push(row) + } + } + + return rows +} + +function extractBindingsArray(parsed: unknown): unknown[] | null { + if (!parsed || typeof parsed !== 'object') { + return null + } + + const root = parsed as { resources?: { bindings?: unknown } } + const bindings = root.resources?.bindings + return Array.isArray(bindings) ? bindings : null +} + +interface RawWranglerBinding { + type?: string + name?: string + [key: string]: unknown +} + +function mapWranglerBindingToRow(raw: unknown): ParsedWranglerBindingRow | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const binding = raw as RawWranglerBinding + const type = typeof binding.type === 'string' ? binding.type : '' + const bindingName = typeof binding.name === 'string' ? binding.name : '' + + if (!type || !bindingName) { + return null + } + + const mapped = mapWranglerBindingType(type, binding) + if (!mapped) { + return null + } + + return { + type: mapped.friendlyType, + bindingName, + resource: mapped.resource + } +} + +function mapWranglerBindingType( + type: string, + binding: RawWranglerBinding +): { friendlyType: string; resource: string } | null { + const stringField = (key: string): string => + typeof binding[key] === 'string' ? binding[key] as string : '' + + switch (type) { + case 'kv_namespace': + return { friendlyType: 'KV Namespace', resource: stringField('namespace_id') } + case 'd1': + return { friendlyType: 'D1 Database', resource: stringField('id') } + case 'r2_bucket': + return { friendlyType: 'R2 Bucket', resource: stringField('bucket_name') } + case 'durable_object_namespace': + return { + friendlyType: 'Durable Object Namespace', + resource: stringField('class_name') + } + case 'queue': + return { friendlyType: 'Queue', resource: stringField('queue_name') } + case 'ratelimit': + return { friendlyType: 'Rate Limiting', resource: stringField('namespace_id') } + case 'service': { + const service = stringField('service') || (binding.name as string) + const entrypoint = stringField('entrypoint') + return { + friendlyType: 'Worker', + resource: entrypoint ? `${service}#${entrypoint}` : service + } + } + case 'ai': + return { friendlyType: 'AI', resource: 'Workers AI' } + case 'vectorize': + return { friendlyType: 'Vectorize', resource: stringField('index_name') } + case 'hyperdrive': + return { friendlyType: 'Hyperdrive', resource: stringField('id') } + case 'browser': + return { friendlyType: 'Browser', resource: 'Browser Rendering' } + case 'analytics_engine': + return { friendlyType: 'Analytics Engine', resource: stringField('dataset') } + case 'send_email': + return { + friendlyType: 'Send Email', + resource: stringField('destination_address') + || stringField('name') + || (binding.name as string) + } + case 'mtls_certificate': + return { friendlyType: 'mTLS Certificate', resource: stringField('certificate_id') } + case 'dispatch_namespace': + return { friendlyType: 'Dispatch Namespace', resource: stringField('namespace') } + case 'workflow': + return { + friendlyType: 'Workflow', + resource: stringField('workflow_name') || stringField('name') + } + case 'pipeline': + return { friendlyType: 'Pipeline', resource: stringField('pipeline') } + case 'images': + return { friendlyType: 'Images', resource: 'Images' } + case 'media': + return { friendlyType: 'Media Transformations', resource: 'Media Transformations' } + case 'artifacts': + return { friendlyType: 'Artifacts', resource: stringField('namespace') } + case 'version_metadata': + return { friendlyType: 'Version Metadata', resource: 'Version Metadata' } + case 'worker_loader': + return { friendlyType: 'Worker Loader', resource: 'Worker Loader' } + case 'secrets_store_secret': + return { + friendlyType: 'Secrets Store', + resource: `${stringField('store_id')}/${stringField('secret_name')}` + } + case 'plain_text': + case 'json': + case 'secret_text': + // Vars / secrets are intentionally ignored โ€” they don't participate + // in cross-worker binding-association inspection. + return null + default: + return { friendlyType: type, resource: stringField('id') || stringField('name') || '' } + } +} + +async function inspectWorkerBindings( + exec: ProcessRunner, + options: { + accountId: string + workerName: string + versionId: string + cwd: string + } +): Promise { + const output = await runWranglerInspectionCommand( + exec, + ['wrangler', 'versions', 'view', options.versionId, '--name', options.workerName, '--json'], + options, + 'Wrangler versions view failed' + ) + + return parseWranglerVersionBindings(output) +} + +async function runWranglerInspectionCommand( + exec: ProcessRunner, + args: string[], + options: { + accountId: string + cwd: string + }, + failureMessage: string +): Promise { + const result = await exec.exec('bunx', args, { + cwd: options.cwd, + env: { + ...process.env, + CLOUDFLARE_ACCOUNT_ID: options.accountId, + FORCE_COLOR: process.env.FORCE_COLOR ?? '0' + } + }) + + if (result.exitCode !== 0) { + throw new Error(result.stderr || result.stdout || failureMessage) + } + + return `${result.stdout}\n${result.stderr}` +} + +async function inspectQueueAssociation( + exec: ProcessRunner, + options: { + accountId: string + queueName: string + cwd: string + } +): Promise { + const output = await runWranglerInspectionCommand( + exec, + ['wrangler', 'queues', 'info', options.queueName], + options, + 'Wrangler queues info failed' + ) + + return parseWranglerQueueInfo(output) +} + +function formatReference(target: BindingAssociationTarget): string { + return target.referenceLabels.length > 0 ? target.referenceLabels.join(', ') : 'โ€”' +} + +function formatResource(target: BindingAssociationTarget): string { + return target.resource || 'โ€”' +} + +function buildRowNotes( + target: BindingAssociationTarget, + queueAssociation: ParsedQueueAssociation | undefined +): string[] { + const notes = [...target.notes] + + if (queueAssociation) { + notes.push(`producers ${queueAssociation.producerWorkers.length}`) + notes.push(`consumers ${queueAssociation.consumerWorkers.length}`) + } + + return uniqueStrings(notes) +} + +export async function inspectBindingAssociations( + options: InspectBindingAssociationsOptions +): Promise { + const targets = collectBindingAssociationTargets(options.config) + const warnings: string[] = [] + const bindingUsage = new Map>() + const scannedWorkers: string[] = [] + const queueTargets = uniqueStrings( + targets + .map((target) => target.queueName) + .filter((queueName): queueName is string => typeof queueName === 'string' && queueName.length > 0) + ) + const queueAssociations = new Map() + + const workers = await account.workers(options.accountId, options.apiOptions) + for (const worker of workers) { + let versionId: string | undefined + + try { + const deployments = await account.workerDeployments( + options.accountId, + worker.name, + options.apiOptions + ) + versionId = getActiveVersionId(deployments) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warnings.push(`Could not resolve active deployment for ${worker.name}: ${message}`) + continue + } + + if (!versionId) { + continue + } + + scannedWorkers.push(worker.name) + + try { + const bindings = await inspectWorkerBindings(options.exec, { + accountId: options.accountId, + workerName: worker.name, + versionId, + cwd: options.cwd + }) + + for (const binding of bindings) { + const key = buildAssociationKey(binding.type, binding.resource) + const connectedWorkers = bindingUsage.get(key) ?? new Set() + connectedWorkers.add(worker.name) + bindingUsage.set(key, connectedWorkers) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warnings.push(`Could not inspect bindings for ${worker.name}: ${message}`) + } + } + + for (const queueName of queueTargets) { + try { + const queueAssociation = await inspectQueueAssociation(options.exec, { + accountId: options.accountId, + queueName, + cwd: options.cwd + }) + + if (queueAssociation) { + queueAssociations.set(queueName, queueAssociation) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warnings.push(`Could not inspect queue ${queueName}: ${message}`) + } + } + + const rows = targets.map((target) => { + const queueAssociation = target.queueName + ? queueAssociations.get(target.queueName) + : undefined + const directlyConnectedWorkers = Array.from(bindingUsage.get(target.key) ?? []) + const connectedWorkers = uniqueStrings([ + ...directlyConnectedWorkers, + ...(queueAssociation?.producerWorkers ?? []), + ...(queueAssociation?.consumerWorkers ?? []) + ]).sort((left, right) => left.localeCompare(right)) + + return { + reference: formatReference(target), + type: target.type, + resource: formatResource(target), + workerCount: connectedWorkers.length, + connectedWorkers, + notes: buildRowNotes(target, queueAssociation), + producerWorkers: queueAssociation?.producerWorkers, + consumerWorkers: queueAssociation?.consumerWorkers + } + }) + + return { + workerName: options.workerName ?? options.config.name, + rows, + targets: targets.length, + scannedWorkers, + warnings + } +} diff --git a/packages/devflare/src/cli/preview.ts b/packages/devflare/src/cli/preview.ts new file mode 100644 index 0000000..af06126 --- /dev/null +++ b/packages/devflare/src/cli/preview.ts @@ -0,0 +1,130 @@ +export { + formatWorkersDevUrl, + formatVersionPreviewUrl +} from '../cloudflare/preview-urls' + +export interface ParsedWranglerDeployOutput { + versionId?: string + previewUrl?: string + urls: string[] +} + +interface WranglerStructuredOutputRecord { + type?: string + version_id?: unknown + targets?: unknown + preview_url?: unknown + preview_urls?: unknown + url?: unknown + urls?: unknown +} + +function matchNamedValue(output: string, patterns: RegExp[]): string | undefined { + for (const pattern of patterns) { + const match = output.match(pattern) + if (match?.[1]) { + return match[1] + } + } + + return undefined +} + +function appendUniqueUrls(target: string[], value: unknown): void { + if (typeof value === 'string') { + if (value.startsWith('http://') || value.startsWith('https://')) { + target.push(value) + } + return + } + + if (!Array.isArray(value)) { + return + } + + for (const item of value) { + appendUniqueUrls(target, item) + } +} + +export function parseWranglerStructuredOutput(output: string): ParsedWranglerDeployOutput { + const records = output + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as WranglerStructuredOutputRecord + } catch { + return null + } + }) + .filter((record): record is WranglerStructuredOutputRecord => record !== null) + + const urls: string[] = [] + let versionId: string | undefined + let previewUrl: string | undefined + + for (const record of records) { + if (!versionId && typeof record.version_id === 'string' && record.version_id.trim()) { + versionId = record.version_id.trim() + } + + appendUniqueUrls(urls, record.targets) + appendUniqueUrls(urls, record.preview_urls) + appendUniqueUrls(urls, record.urls) + appendUniqueUrls(urls, record.url) + + if (!previewUrl && typeof record.preview_url === 'string' && record.preview_url.trim()) { + previewUrl = record.preview_url.trim() + } + } + + const uniqueUrls = [...new Set(urls)] + + if (!previewUrl) { + previewUrl = uniqueUrls.find((url) => url.includes('workers.dev')) + } + + return { + versionId, + previewUrl, + urls: uniqueUrls + } +} + +export function mergeParsedWranglerDeployOutputs( + ...outputs: ParsedWranglerDeployOutput[] +): ParsedWranglerDeployOutput { + const urls = [...new Set(outputs.flatMap((output) => output.urls))] + + return { + versionId: outputs.map((output) => output.versionId).find((value) => Boolean(value)), + previewUrl: outputs.map((output) => output.previewUrl).find((value) => Boolean(value)) ?? urls.find((url) => url.includes('workers.dev')), + urls + } +} + +export function parseWranglerDeployOutput(output: string): ParsedWranglerDeployOutput { + const normalizedOutput = output.replace(/\r/g, '') + const urls = [...new Set(normalizedOutput.match(/https?:\/\/[^\s'"`]+/g) ?? [])] + + const previewUrl = matchNamedValue(normalizedOutput, [ + /Preview URL:\s*(https?:\/\/\S+)/i, + /Version Preview URL:\s*(https?:\/\/\S+)/i + ]) ?? urls.find((url) => url.includes('workers.dev')) + + const versionId = matchNamedValue(normalizedOutput, [ + /Worker Version ID:\s*([A-Za-z0-9_-]+)/i, + /Version ID:\s*([A-Za-z0-9_-]+)/i, + /version(?:_id| id)?\s*[:=]\s*([A-Za-z0-9_-]+)/i, + /"id"\s*:\s*"([A-Za-z0-9_-]+)"/i + ]) + + return { + versionId, + previewUrl, + urls + } +} \ No newline at end of file diff --git a/packages/devflare/src/cli/ui.ts b/packages/devflare/src/cli/ui.ts new file mode 100644 index 0000000..b29306f --- /dev/null +++ b/packages/devflare/src/cli/ui.ts @@ -0,0 +1,199 @@ +import type { ConsolaInstance } from 'consola' +import { BOLD, CYAN, CYAN_BOLD, DIM, GREEN, RED, RESET, WHITE, YELLOW } from './colors' + +const ANSI_REGEX = /\x1b\[[0-9;]*m/g + +export interface CliTheme { + useColor: boolean +} + +export type CliAccent = 'cyan' | 'yellow' | 'green' | 'red' | 'dim' | 'bold' | 'white-dim' + +export interface CliTableColumn { + label: string + width?: number + value: (row: Row) => string +} + +export function createCliTheme(options: Record = {}): CliTheme { + if (options['no-color'] === true) { + return { useColor: false } + } + + if (process.env.NO_COLOR?.trim()) { + return { useColor: false } + } + + if (process.env.TERM === 'dumb') { + return { useColor: false } + } + + return { + useColor: process.stdout?.isTTY === true + } +} + +export function paint(value: string, code: string, theme: CliTheme): string { + return theme.useColor ? `${code}${value}${RESET}` : value +} + +export function dim(value: string, theme: CliTheme): string { + return paint(value, DIM, theme) +} + +export function bold(value: string, theme: CliTheme): string { + return paint(value, BOLD, theme) +} + +export function cyan(value: string, theme: CliTheme): string { + return paint(value, CYAN, theme) +} + +export function cyanBold(value: string, theme: CliTheme): string { + return paint(value, CYAN_BOLD, theme) +} + +export function green(value: string, theme: CliTheme): string { + return paint(value, GREEN, theme) +} + +export function yellow(value: string, theme: CliTheme): string { + return paint(value, YELLOW, theme) +} + +export function yellowBold(value: string, theme: CliTheme): string { + return paint(value, `${BOLD}${YELLOW}`, theme) +} + +export function red(value: string, theme: CliTheme): string { + return paint(value, RED, theme) +} + +export function whiteDim(value: string, theme: CliTheme): string { + return paint(value, `${DIM}${WHITE}`, theme) +} + +export function accent(value: string, theme: CliTheme, kind: CliAccent = 'cyan'): string { + switch (kind) { + case 'yellow': + return yellowBold(value, theme) + case 'green': + return green(value, theme) + case 'red': + return red(value, theme) + case 'dim': + return dim(value, theme) + case 'bold': + return bold(value, theme) + case 'white-dim': + return whiteDim(value, theme) + case 'cyan': + default: + return cyanBold(value, theme) + } +} + +export function logLine(logger: ConsolaInstance, message: string = ''): void { + logger.log(message) +} + +export function stripAnsi(value: string): string { + return value.replace(ANSI_REGEX, '') +} + +function truncateCell(value: string, width: number): string { + if (value.length <= width) { + return value + } + + if (width <= 1) { + return 'โ€ฆ' + } + + return `${value.slice(0, width - 1)}โ€ฆ` +} + +function truncateStyledCell(value: string, width: number): string { + const plainValue = stripAnsi(value) + if (plainValue.length <= width) { + return value + } + + const truncatedPlainValue = truncateCell(plainValue, width) + const prefixMatch = value.match(/^((?:\x1b\[[0-9;]*m)+)/) + const suffixMatch = value.match(/((?:\x1b\[[0-9;]*m)+)$/) + const prefix = prefixMatch?.[1] ?? '' + const suffix = prefix ? RESET : suffixMatch?.[1] ?? '' + + return `${prefix}${truncatedPlainValue}${suffix}` +} + +function padStyledCell(value: string, width: number): string { + const truncatedValue = truncateStyledCell(value, width) + const visibleLength = stripAnsi(truncatedValue).length + return `${truncatedValue}${' '.repeat(Math.max(width - visibleLength, 0))}` +} + +export function formatTableLine(values: string[], widths: Array): string { + return values.map((value, index) => { + const width = widths[index] + if (width === undefined || index === values.length - 1) { + return value + } + + return padStyledCell(value, width) + }).join(' ') +} + +export function renderTable( + rows: Row[], + columns: CliTableColumn[], + theme: CliTheme +): string[] { + if (rows.length === 0) { + return [] + } + + const widths = columns.map((column) => column.width) + return [ + formatTableLine(columns.map((column) => dim(column.label, theme)), widths), + ...rows.map((row) => formatTableLine(columns.map((column) => column.value(row)), widths)) + ] +} + +export function logTable( + logger: ConsolaInstance, + options: { + title: string + rows: Row[] + columns: CliTableColumn[] + theme: CliTheme + titleAccent?: CliAccent + } +): void { + if (options.rows.length === 0) { + return + } + + logLine(logger, `${accent(options.title, options.theme, options.titleAccent)} ${dim(`(${options.rows.length})`, options.theme)}`) + for (const line of renderTable(options.rows, options.columns, options.theme)) { + logLine(logger, line) + } +} + +export function formatLabelValue( + label: string, + value: string, + theme: CliTheme, + labelWidth: number = 12 +): string { + return `${dim(label.padEnd(labelWidth), theme)} ${value}` +} + +export function formatCommand(command: string, description: string, theme: CliTheme): string { + return ` ${cyan(command, theme)}${dim(' โ€” ', theme)}${description}` +} + +export function formatBullet(text: string, theme: CliTheme, bullet: string = 'โ€ข'): string { + return ` ${dim(bullet, theme)} ${text}` +} diff --git a/packages/devflare/src/cli/workspace-build-guard.ts b/packages/devflare/src/cli/workspace-build-guard.ts new file mode 100644 index 0000000..35c571e --- /dev/null +++ b/packages/devflare/src/cli/workspace-build-guard.ts @@ -0,0 +1,184 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { dirname, join } from 'pathe' +import { fileURLToPath } from 'node:url' + +export interface LocalWorkspaceBuildStatus { + state: 'not-applicable' | 'fresh' | 'missing-dist' | 'stale' + packageRoot?: string + sourceNewestAt?: Date + distNewestAt?: Date +} + +function isTruthyEnvFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return normalized !== undefined && ['1', 'true', 'yes', 'on'].includes(normalized) +} + +function isCiEnvironment(env: NodeJS.ProcessEnv): boolean { + return isTruthyEnvFlag(env.CI) || isTruthyEnvFlag(env.GITHUB_ACTIONS) +} + +async function pathExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function readPackageName(packageRoot: string): Promise { + try { + const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as { + name?: unknown + } + + return typeof packageJson.name === 'string' ? packageJson.name : undefined + } catch { + return undefined + } +} + +async function findLocalDevflarePackageRoot(startDirectory: string): Promise { + let currentDirectory = startDirectory + + while (true) { + if (await pathExists(join(currentDirectory, 'package.json'))) { + const packageName = await readPackageName(currentDirectory) + if (packageName === 'devflare') { + return currentDirectory + } + } + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) { + return undefined + } + + currentDirectory = parentDirectory + } +} + +async function getLatestModifiedTime(path: string): Promise { + if (!await pathExists(path)) { + return undefined + } + + const entry = await stat(path) + if (entry.isFile()) { + return entry.mtimeMs + } + + if (!entry.isDirectory()) { + return undefined + } + + let newestModifiedTime = 0 + const children = await readdir(path, { withFileTypes: true }) + + for (const child of children) { + if (child.name === 'node_modules' || child.name === '.turbo') { + continue + } + + const childModifiedTime = await getLatestModifiedTime(join(path, child.name)) + if (typeof childModifiedTime === 'number' && childModifiedTime > newestModifiedTime) { + newestModifiedTime = childModifiedTime + } + } + + return newestModifiedTime > 0 ? newestModifiedTime : entry.mtimeMs +} + +export async function getLocalWorkspaceBuildStatus(options: { + packageRoot?: string +} = {}): Promise { + const packageRoot = options.packageRoot + ?? await findLocalDevflarePackageRoot(dirname(fileURLToPath(import.meta.url))) + + if (!packageRoot) { + return { + state: 'not-applicable' + } + } + + const sourceDirectory = join(packageRoot, 'src') + if (!await pathExists(sourceDirectory)) { + return { + state: 'not-applicable', + packageRoot + } + } + + const distDirectory = join(packageRoot, 'dist') + if (!await pathExists(distDirectory)) { + const sourceNewestAt = await getLatestModifiedTime(sourceDirectory) + return { + state: 'missing-dist', + packageRoot, + ...(typeof sourceNewestAt === 'number' + ? { sourceNewestAt: new Date(sourceNewestAt) } + : {}) + } + } + + const [sourceNewestAt, distNewestAt] = await Promise.all([ + getLatestModifiedTime(sourceDirectory), + getLatestModifiedTime(distDirectory) + ]) + + if (typeof sourceNewestAt !== 'number' || typeof distNewestAt !== 'number') { + return { + state: 'not-applicable', + packageRoot + } + } + + return { + state: sourceNewestAt > distNewestAt ? 'stale' : 'fresh', + packageRoot, + sourceNewestAt: new Date(sourceNewestAt), + distNewestAt: new Date(distNewestAt) + } +} + +export async function getLocalWorkspaceBuildGuardMessage( + command: string, + options: { + packageRoot?: string + env?: NodeJS.ProcessEnv + } = {} +): Promise { + if (!['build', 'deploy', 'types'].includes(command)) { + return undefined + } + + const env = options.env ?? process.env + if (isTruthyEnvFlag(env.DEVFLARE_SKIP_WORKSPACE_BUILD_GUARD)) { + return undefined + } + + if (isCiEnvironment(env)) { + return undefined + } + + const status = await getLocalWorkspaceBuildStatus({ + packageRoot: options.packageRoot + }) + if (status.state === 'fresh' || status.state === 'not-applicable') { + return undefined + } + + const timestampSummary = status.sourceNewestAt + ? ` Latest source change: ${status.sourceNewestAt.toISOString()}.` + : '' + const distTimestampSummary = status.distNewestAt + ? ` Latest dist build: ${status.distNewestAt.toISOString()}.` + : '' + + if (status.state === 'missing-dist') { + return `Local Devflare workspace exports are missing. Running \`devflare ${command}\` from this repository can mix the live CLI source with missing runtime exports. Run \`bun run --cwd packages/devflare build\` first, or set DEVFLARE_SKIP_WORKSPACE_BUILD_GUARD=true to bypass this guard.${timestampSummary}` + } + + return `Local Devflare workspace exports are stale. Running \`devflare ${command}\` from this repository can mix newer CLI source with older \`devflare/runtime\` exports.${timestampSummary}${distTimestampSummary} Run \`bun run --cwd packages/devflare build\` first, or set DEVFLARE_SKIP_WORKSPACE_BUILD_GUARD=true to bypass this guard.` +} \ No newline at end of file diff --git a/packages/devflare/src/cli/wrangler-auth.ts b/packages/devflare/src/cli/wrangler-auth.ts new file mode 100644 index 0000000..2fc8342 --- /dev/null +++ b/packages/devflare/src/cli/wrangler-auth.ts @@ -0,0 +1,159 @@ +// ============================================================================= +// Wrangler Authentication Utilities +// ============================================================================= +// Utilities for checking wrangler login status and remote binding requirements +// ============================================================================= + +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import type { DevflareConfig } from '../config/schema' + +const execAsync = promisify(exec) + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface WranglerAuthStatus { + loggedIn: boolean + accountId?: string + email?: string + error?: string +} + +export interface RemoteBindingCheck { + hasRemoteBindings: boolean + remoteBindings: string[] + missingAccountId: boolean + notLoggedIn: boolean +} + +// ----------------------------------------------------------------------------- +// Remote Binding Detection +// ----------------------------------------------------------------------------- + +/** + * Check if the config contains any bindings that require remote access + */ +export function detectRemoteBindings(config: DevflareConfig): string[] { + const remoteBindings: string[] = [] + const bindings = config.bindings + + if (!bindings) return remoteBindings + + // AI binding + if (bindings.ai) { + remoteBindings.push(`AI (binding: ${bindings.ai.binding})`) + } + + // Vectorize bindings + if (bindings.vectorize) { + for (const [name] of Object.entries(bindings.vectorize)) { + remoteBindings.push(`Vectorize (binding: ${name})`) + } + } + + return remoteBindings +} + +// ----------------------------------------------------------------------------- +// Wrangler Auth Check +// ----------------------------------------------------------------------------- + +/** + * Check if wrangler is logged in by running `wrangler whoami` + * + * Returns: + * - loggedIn: true if user is authenticated + * - accountId: the account ID if available + * - email: the email if available + * - error: error message if check failed + */ +export async function checkWranglerAuth(): Promise { + try { + const { stdout, stderr } = await execAsync('bunx wrangler whoami', { + timeout: 15000 // 15 second timeout + }) + + const output = stdout + stderr + + // Check for "not authenticated" or similar messages + if ( + output.includes('not authenticated') || + output.includes('Not logged in') || + output.includes('wrangler login') + ) { + return { + loggedIn: false, + error: 'Not logged in to Wrangler' + } + } + + // Try to extract account info from output + // Example output: "๐Ÿ‘‹ You are logged in with an OAuth Token, associated with the email example@domain.com!" + // Or: "Account ID: abc123..." + const emailMatch = output.match(/email[:\s]+([^\s!]+)/i) + const accountMatch = output.match(/Account\s+ID[:\s]+([a-f0-9]+)/i) + + return { + loggedIn: true, + email: emailMatch?.[1], + accountId: accountMatch?.[1] + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + + // If wrangler command fails, user is likely not logged in + if (msg.includes('ENOENT') || msg.includes('not found')) { + return { + loggedIn: false, + error: 'Wrangler not installed. Run: npm install -g wrangler' + } + } + + return { + loggedIn: false, + error: msg + } + } +} + +// ----------------------------------------------------------------------------- +// Combined Check +// ----------------------------------------------------------------------------- + +/** + * Check if remote bindings are properly configured + * + * Returns warnings if: + * - Config has remote-only bindings but no accountId + * - Config has remote-only bindings but user is not logged in to wrangler + */ +export async function checkRemoteBindingRequirements( + config: DevflareConfig +): Promise { + const remoteBindings = detectRemoteBindings(config) + + if (remoteBindings.length === 0) { + return { + hasRemoteBindings: false, + remoteBindings: [], + missingAccountId: false, + notLoggedIn: false + } + } + + // Check if accountId is set + const missingAccountId = !config.accountId + + // Check wrangler auth status + const authStatus = await checkWranglerAuth() + const notLoggedIn = !authStatus.loggedIn + + return { + hasRemoteBindings: true, + remoteBindings, + missingAccountId, + notLoggedIn + } +} diff --git a/packages/devflare/src/cloudflare/account-core.ts b/packages/devflare/src/cloudflare/account-core.ts new file mode 100644 index 0000000..1db99b9 --- /dev/null +++ b/packages/devflare/src/cloudflare/account-core.ts @@ -0,0 +1,32 @@ +import { apiGet, apiGetAll, type APIClientOptions } from './api' +import type { AccountInfo, CloudflareAccount } from './types' + +export async function getAccounts(options?: APIClientOptions): Promise { + const accounts = await apiGetAll('/accounts', options) + + return accounts.map((account) => ({ + id: account.id, + name: account.name, + type: account.type, + createdOn: account.created_on ? new Date(account.created_on) : undefined + })) +} + +export async function getPrimaryAccount(options?: APIClientOptions): Promise { + const accounts = await getAccounts(options) + return accounts[0] ?? null +} + +export async function getAccountById(accountId: string, options?: APIClientOptions): Promise { + try { + const account = await apiGet(`/accounts/${accountId}`, options) + return { + id: account.id, + name: account.name, + type: account.type, + createdOn: account.created_on ? new Date(account.created_on) : undefined + } + } catch { + return null + } +} diff --git a/packages/devflare/src/cloudflare/account-resources.ts b/packages/devflare/src/cloudflare/account-resources.ts new file mode 100644 index 0000000..b4f1f74 --- /dev/null +++ b/packages/devflare/src/cloudflare/account-resources.ts @@ -0,0 +1,388 @@ +import { apiDelete, apiGetAll, apiPost, CloudflareAPIError, type APIClientOptions } from './api' +import type { + AIModel, + AIModelInfo, + D1Database, + D1DatabaseInfo, + D1QueryParameter, + D1QueryResult, + D1RawQueryResult, + HyperdriveConfig, + HyperdriveConfigInfo, + KVNamespace, + KVNamespaceInfo, + Queue, + QueueInfo, + R2Bucket, + R2BucketInfo, + VectorizeIndex, + VectorizeIndexInfo +} from './types' + +export async function listKVNamespaces( + accountId: string, + options?: APIClientOptions +): Promise { + const namespaces = await apiGetAll( + `/accounts/${accountId}/storage/kv/namespaces`, + options + ) + + return namespaces.map((namespace) => ({ + id: namespace.id, + name: namespace.title + })) +} + +export async function createKVNamespace( + accountId: string, + title: string, + options?: APIClientOptions +): Promise { + const namespace = await apiPost( + `/accounts/${accountId}/storage/kv/namespaces`, + { title }, + options + ) + + return { + id: namespace.id, + name: namespace.title + } +} + +export async function deleteKVNamespace( + accountId: string, + namespaceId: string, + options?: APIClientOptions +): Promise { + const encodedNamespaceId = encodeURIComponent(namespaceId) + await apiDelete<{}>( + `/accounts/${accountId}/storage/kv/namespaces/${encodedNamespaceId}`, + options + ) +} + +export async function listD1Databases( + accountId: string, + options?: APIClientOptions +): Promise { + const databases = await apiGetAll( + `/accounts/${accountId}/d1/database`, + options + ) + + return databases.map((database) => ({ + id: database.uuid, + name: database.name, + version: database.version, + tableCount: database.num_tables, + sizeBytes: database.file_size + })) +} + +export async function createD1Database( + accountId: string, + name: string, + options?: APIClientOptions & { + jurisdiction?: 'eu' | 'fedramp' + primaryLocationHint?: 'wnam' | 'enam' | 'weur' | 'eeur' | 'apac' | 'oc' + } +): Promise { + const created = await apiPost( + `/accounts/${accountId}/d1/database`, + { + name, + ...(options?.jurisdiction ? { jurisdiction: options.jurisdiction } : {}), + ...(options?.primaryLocationHint ? { primary_location_hint: options.primaryLocationHint } : {}) + }, + options + ) + + return { + id: created.uuid, + name: created.name, + version: created.version, + tableCount: created.num_tables, + sizeBytes: created.file_size + } +} + +export async function deleteD1Database( + accountId: string, + databaseId: string, + options?: APIClientOptions +): Promise { + const encodedDatabaseId = encodeURIComponent(databaseId) + await apiDelete<{}>( + `/accounts/${accountId}/d1/database/${encodedDatabaseId}`, + options + ) +} + +export async function queryD1Database>( + accountId: string, + databaseId: string, + query: { + sql: string + params?: D1QueryParameter[] + }, + options?: APIClientOptions +): Promise[]> { + const { apiPost } = await import('./api') + return apiPost[]>( + `/accounts/${accountId}/d1/database/${databaseId}/query`, + query, + options + ) +} + +export async function rawD1DatabaseQuery( + accountId: string, + databaseId: string, + query: { + sql: string + params?: D1QueryParameter[] + }, + options?: APIClientOptions +): Promise { + const { apiPost } = await import('./api') + return apiPost( + `/accounts/${accountId}/d1/database/${databaseId}/raw`, + query, + options + ) +} + +export async function listQueues( + accountId: string, + options?: APIClientOptions +): Promise { + const queues = await apiGetAll( + `/accounts/${accountId}/queues`, + options + ) + + return queues + .filter((queue): queue is Queue & { queue_id: string; queue_name: string } => { + return typeof queue.queue_id === 'string' && queue.queue_id.length > 0 + && typeof queue.queue_name === 'string' && queue.queue_name.length > 0 + }) + .map((queue) => ({ + id: queue.queue_id, + name: queue.queue_name, + createdOn: queue.created_on ? new Date(queue.created_on) : undefined, + modifiedOn: queue.modified_on ? new Date(queue.modified_on) : undefined, + deliveryDelay: queue.settings?.delivery_delay, + deliveryPaused: queue.settings?.delivery_paused, + messageRetentionPeriod: queue.settings?.message_retention_period + })) +} + +export async function createQueue( + accountId: string, + queueName: string, + options?: APIClientOptions +): Promise { + const queue = await apiPost( + `/accounts/${accountId}/queues`, + { queue_name: queueName }, + options + ) + + return { + id: queue.queue_id ?? '', + name: queue.queue_name ?? queueName, + createdOn: queue.created_on ? new Date(queue.created_on) : undefined, + modifiedOn: queue.modified_on ? new Date(queue.modified_on) : undefined, + deliveryDelay: queue.settings?.delivery_delay, + deliveryPaused: queue.settings?.delivery_paused, + messageRetentionPeriod: queue.settings?.message_retention_period + } +} + +export async function deleteQueue( + accountId: string, + queueId: string, + options?: APIClientOptions +): Promise { + const encodedQueueId = encodeURIComponent(queueId) + await apiDelete<{}>( + `/accounts/${accountId}/queues/${encodedQueueId}`, + options + ) +} + +export async function listR2Buckets( + accountId: string, + options?: APIClientOptions +): Promise { + const buckets = await apiGetAll( + `/accounts/${accountId}/r2/buckets`, + options + ) + + return buckets.map((bucket) => ({ + name: bucket.name, + createdOn: new Date(bucket.creation_date), + location: bucket.location + })) +} + +export async function createR2Bucket( + accountId: string, + name: string, + options?: APIClientOptions & { + locationHint?: 'apac' | 'eeur' | 'enam' | 'oc' | 'weur' | 'wnam' + storageClass?: 'Standard' | 'InfrequentAccess' + } +): Promise { + const bucket = await apiPost( + `/accounts/${accountId}/r2/buckets`, + { + name, + ...(options?.locationHint ? { locationHint: options.locationHint } : {}), + ...(options?.storageClass ? { storageClass: options.storageClass } : {}) + }, + options + ) + + return { + name: bucket.name, + createdOn: bucket.creation_date ? new Date(bucket.creation_date) : new Date(), + location: bucket.location + } +} + +export async function deleteR2Bucket( + accountId: string, + bucketName: string, + options?: APIClientOptions +): Promise { + const encodedBucketName = encodeURIComponent(bucketName) + await apiDelete<{}>( + `/accounts/${accountId}/r2/buckets/${encodedBucketName}`, + options + ) +} + +export async function listHyperdrives( + accountId: string, + options?: APIClientOptions +): Promise { + const hyperdrives = await apiGetAll( + `/accounts/${accountId}/hyperdrive/configs`, + options + ) + + return hyperdrives.map((hyperdrive) => ({ + id: hyperdrive.id, + name: hyperdrive.name, + createdOn: hyperdrive.created_on ? new Date(hyperdrive.created_on) : undefined, + modifiedOn: hyperdrive.modified_on ? new Date(hyperdrive.modified_on) : undefined + })) +} + +export async function deleteHyperdrive( + accountId: string, + hyperdriveId: string, + options?: APIClientOptions +): Promise { + const encodedHyperdriveId = encodeURIComponent(hyperdriveId) + await apiDelete<{}>( + `/accounts/${accountId}/hyperdrive/configs/${encodedHyperdriveId}`, + options + ) +} + +export async function listVectorizeIndexes( + accountId: string, + options?: APIClientOptions +): Promise { + try { + const indexes = await apiGetAll( + `/accounts/${accountId}/vectorize/v2/indexes`, + options + ) + + return indexes.map((index) => ({ + name: index.name, + dimensions: index.config.dimensions, + metric: index.config.metric, + description: index.description + })) + } catch (error) { + // Swallow only "endpoint not available on this account" style errors + // (404). Any other failure โ€” auth (401/403), rate limit (429), 5xx โ€” + // must be surfaced so callers don't silently treat the account as + // empty and re-create resources or skip a validation step. + if (error instanceof CloudflareAPIError && error.code === 404) { + return [] + } + throw error + } +} + +export async function createVectorizeIndex( + accountId: string, + index: { + name: string + dimensions: number + metric: 'cosine' | 'euclidean' | 'dot-product' | string + description?: string + }, + options?: APIClientOptions +): Promise { + const created = await apiPost( + `/accounts/${accountId}/vectorize/v2/indexes`, + { + name: index.name, + config: { + dimensions: index.dimensions, + metric: index.metric + }, + ...(index.description ? { description: index.description } : {}) + }, + options + ) + + return { + name: created.name, + dimensions: created.config.dimensions, + metric: created.config.metric, + description: created.description + } +} + +export async function deleteVectorizeIndex( + accountId: string, + indexName: string, + options?: APIClientOptions +): Promise { + const encodedIndexName = encodeURIComponent(indexName) + await apiDelete<{}>( + `/accounts/${accountId}/vectorize/v2/indexes/${encodedIndexName}`, + options + ) +} + +export async function listAIModels( + accountId: string, + options?: APIClientOptions +): Promise { + try { + const models = await apiGetAll( + `/accounts/${accountId}/ai/models/search`, + options + ) + + return models.map((model) => ({ + id: model.id, + name: model.name, + task: model.task?.name, + description: model.description + })) + } catch { + return [] + } +} diff --git a/packages/devflare/src/cloudflare/account-status.ts b/packages/devflare/src/cloudflare/account-status.ts new file mode 100644 index 0000000..8d34151 --- /dev/null +++ b/packages/devflare/src/cloudflare/account-status.ts @@ -0,0 +1,126 @@ +import { isAuthenticated } from './auth' +import type { + AccountInfo, + CloudflareService, + ServiceStatus +} from './types' +import { getAccountById } from './account-core' +import { listWorkers } from './account-workers' +import { + listAIModels, + listD1Databases, + listHyperdrives, + listKVNamespaces, + listR2Buckets, + listVectorizeIndexes +} from './account-resources' + +const SERVICE_STATUS_TIMEOUT_MS = 10000 + +type ServiceInventoryFetcher = (accountId: string) => Promise + +const serviceInventoryFetchers: Partial> = { + workers: listWorkers, + kv: listKVNamespaces, + d1: listD1Databases, + hyperdrive: listHyperdrives, + r2: listR2Buckets, + vectorize: listVectorizeIndexes, + ai: listAIModels +} + +async function withServiceTimeout(operation: Promise): Promise { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('timeout')), SERVICE_STATUS_TIMEOUT_MS) + }) + + try { + return await Promise.race([ + operation, + timeoutPromise + ]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +function createAvailableServiceStatus( + service: CloudflareService, + count: number +): ServiceStatus { + return { + service, + available: service === 'ai' ? count > 0 : true, + count + } +} + +export async function getServiceStatus( + accountId: string, + service: CloudflareService +): Promise { + const fetchInventory = serviceInventoryFetchers[service] + if (!fetchInventory) { + return { + service, + available: false + } + } + + try { + const inventory = await withServiceTimeout(fetchInventory(accountId)) + return createAvailableServiceStatus(service, inventory.length) + } catch { + return { + service, + available: false + } + } +} + +export async function getAllServiceStatus(accountId: string): Promise { + const services: CloudflareService[] = [ + 'workers', + 'kv', + 'd1', + 'hyperdrive', + 'r2', + 'vectorize', + 'ai' + ] + + return Promise.all(services.map((service) => getServiceStatus(accountId, service))) +} + +export async function checkAuth(): Promise { + return isAuthenticated() +} + +export async function hasService( + accountId: string, + service: CloudflareService +): Promise { + const status = await getServiceStatus(accountId, service) + return status.available +} + +export interface AccountSummary { + account: AccountInfo + services: ServiceStatus[] +} + +export async function getAccountSummary(accountId: string): Promise { + const account = await getAccountById(accountId) + if (!account) { + return null + } + + const services = await getAllServiceStatus(accountId) + return { + account, + services + } +} diff --git a/packages/devflare/src/cloudflare/account-workers.ts b/packages/devflare/src/cloudflare/account-workers.ts new file mode 100644 index 0000000..13af41e --- /dev/null +++ b/packages/devflare/src/cloudflare/account-workers.ts @@ -0,0 +1,215 @@ +import { apiDelete, apiGet, apiGetAll, apiPatch, type APIClientOptions } from './api' +import type { + WorkerDeploymentInfo, + WorkerInfo, + WorkerScript, + WorkerVersionInfo +} from './types' + +interface WorkersSubdomainResponse { + subdomain: string +} + +interface WorkerVersionsListResult { + items?: Array<{ + id?: string + number?: number + metadata?: { + author_email?: string + author_id?: string + created_on?: string + modified_on?: string + has_preview?: boolean + hasPreview?: boolean + source?: string + } + }> +} + +interface WorkerVersionDetailResult { + id?: string + number?: number + metadata?: { + author_email?: string + author_id?: string + created_on?: string + modified_on?: string + has_preview?: boolean + hasPreview?: boolean + source?: string + } +} + +interface WorkerDeploymentsListResult { + deployments?: Array<{ + id: string + created_on: string + source: string + strategy: string + versions: Array<{ + percentage: number + version_id: string + }> + annotations?: { + 'workers/message'?: string + 'workers/triggered_by'?: string + } + author_email?: string + }> +} + +interface EditWorkerResult { + id: string + name: string +} + +export interface RenamedWorkerInfo { + id: string + name: string +} + +export async function listWorkers( + accountId: string, + options?: APIClientOptions +): Promise { + const scripts = await apiGetAll( + `/accounts/${accountId}/workers/scripts`, + options + ) + + return scripts.map((script) => ({ + name: script.name ?? script.id, + createdOn: new Date(script.created_on), + modifiedOn: new Date(script.modified_on) + })) +} + +export async function renameWorker( + accountId: string, + workerId: string, + newName: string, + options?: APIClientOptions +): Promise { + const encodedWorkerId = encodeURIComponent(workerId) + const result = await apiPatch( + `/accounts/${accountId}/workers/workers/${encodedWorkerId}`, + { name: newName }, + options + ) + + return { + id: result.id, + name: result.name + } +} + +export async function deleteWorker( + accountId: string, + scriptName: string, + options?: APIClientOptions +): Promise { + const encodedScriptName = encodeURIComponent(scriptName) + await apiDelete( + `/accounts/${accountId}/workers/scripts/${encodedScriptName}`, + options + ) +} + +function mapWorkerVersionInfo( + version: NonNullable[number] | WorkerVersionDetailResult +): WorkerVersionInfo { + return { + id: version.id ?? '', + number: version.number, + metadata: { + authorEmail: version.metadata?.author_email, + authorId: version.metadata?.author_id, + createdOn: version.metadata?.created_on ? new Date(version.metadata.created_on) : undefined, + modifiedOn: version.metadata?.modified_on ? new Date(version.metadata.modified_on) : undefined, + hasPreview: version.metadata?.has_preview === true || version.metadata?.hasPreview === true, + source: version.metadata?.source + } + } +} + +export async function listWorkerVersions( + accountId: string, + scriptName: string, + options?: APIClientOptions +): Promise { + const versions: WorkerVersionInfo[] = [] + const encodedScriptName = encodeURIComponent(scriptName) + + for (let page = 1;page <= 100;page++) { + const result = await apiGet( + `/accounts/${accountId}/workers/scripts/${encodedScriptName}/versions?page=${page}&per_page=100`, + options + ) + + const items = result.items ?? [] + versions.push(...items.map((item) => mapWorkerVersionInfo(item))) + + if (items.length < 100) { + break + } + } + + return versions +} + +export async function getWorkerVersionDetail( + accountId: string, + scriptName: string, + versionId: string, + options?: APIClientOptions +): Promise { + const encodedScriptName = encodeURIComponent(scriptName) + const result = await apiGet( + `/accounts/${accountId}/workers/scripts/${encodedScriptName}/versions/${versionId}`, + options + ) + + return mapWorkerVersionInfo(result) +} + +export async function listWorkerDeployments( + accountId: string, + scriptName: string, + options?: APIClientOptions +): Promise { + const encodedScriptName = encodeURIComponent(scriptName) + const result = await apiGet( + `/accounts/${accountId}/workers/scripts/${encodedScriptName}/deployments`, + options + ) + + return (result.deployments ?? []).map((deployment) => ({ + id: deployment.id, + createdOn: new Date(deployment.created_on), + source: deployment.source, + strategy: deployment.strategy, + versions: deployment.versions.map((version) => ({ + percentage: version.percentage, + versionId: version.version_id + })), + message: deployment.annotations?.['workers/message'], + triggeredBy: deployment.annotations?.['workers/triggered_by'], + authorEmail: deployment.author_email + })) +} + +export async function getWorkersSubdomain( + accountId: string, + options?: APIClientOptions +): Promise { + try { + const result = await apiGet( + `/accounts/${accountId}/workers/subdomain`, + options + ) + + return result.subdomain || null + } catch { + return null + } +} diff --git a/packages/devflare/src/cloudflare/account.ts b/packages/devflare/src/cloudflare/account.ts new file mode 100644 index 0000000..6355585 --- /dev/null +++ b/packages/devflare/src/cloudflare/account.ts @@ -0,0 +1,63 @@ +// ============================================================================= +// Cloudflare Account Module +// ============================================================================= +// Re-exports focused account, worker, resource, and status modules +// ============================================================================= + +export { + getAccounts, + getPrimaryAccount, + getAccountById +} from './account-core' + +export { + listWorkers, + renameWorker, + deleteWorker, + listWorkerVersions, + getWorkerVersionDetail, + listWorkerDeployments, + getWorkersSubdomain +} from './account-workers' + +export { + listKVNamespaces, + createKVNamespace, + deleteKVNamespace, + listD1Databases, + createD1Database, + deleteD1Database, + queryD1Database, + rawD1DatabaseQuery, + listQueues, + createQueue, + deleteQueue, + listR2Buckets, + createR2Bucket, + deleteR2Bucket, + listHyperdrives, + deleteHyperdrive, + listVectorizeIndexes, + createVectorizeIndex, + deleteVectorizeIndex, + listAIModels +} from './account-resources' + +export { + getServiceStatus, + getAllServiceStatus, + checkAuth, + hasService, + getAccountSummary +} from './account-status' + +export type { RenamedWorkerInfo } from './account-workers' +export type { AccountSummary } from './account-status' +export type { + D1DatabaseInfo, + HyperdriveConfigInfo, + KVNamespaceInfo, + QueueInfo, + R2BucketInfo, + VectorizeIndexInfo +} from './types' diff --git a/packages/devflare/src/cloudflare/api.ts b/packages/devflare/src/cloudflare/api.ts new file mode 100644 index 0000000..41c4250 --- /dev/null +++ b/packages/devflare/src/cloudflare/api.ts @@ -0,0 +1,732 @@ +// ============================================================================= +// Cloudflare REST API Client +// ============================================================================= +// HTTP client for interacting with Cloudflare's v4 API +// ============================================================================= + +import { getApiToken, invalidateToken } from './auth' +import type { CloudflareAPIResponse } from './types' + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +const API_BASE = 'https://api.cloudflare.com/client/v4' +const DEFAULT_TIMEOUT = 10000 // 10 seconds + +// ----------------------------------------------------------------------------- +// Error Types +// ----------------------------------------------------------------------------- + +export class CloudflareAPIError extends Error { + constructor( + message: string, + public code: number, + public errors: Array<{ code: number; message: string }> + ) { + super(message) + this.name = 'CloudflareAPIError' + } +} + +export class AuthenticationError extends Error { + constructor(message = 'Not authenticated. Run: devflare login') { + super(message) + this.name = 'AuthenticationError' + } +} + +// ----------------------------------------------------------------------------- +// Fetch with timeout +// ----------------------------------------------------------------------------- + +/** + * Wrap fetch with a guaranteed timeout using Promise.race + * AbortSignal can sometimes fail to abort in certain environments + */ +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number +): Promise { + const controller = new AbortController() + const abortTimeoutId = setTimeout(() => controller.abort(), timeoutMs) + let rejectTimeoutId: ReturnType | null = null + const timeoutPromise = new Promise((_, reject) => { + rejectTimeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) + }) + + try { + const response = await Promise.race([ + fetch(url, { ...init, signal: controller.signal }), + timeoutPromise + ]) + return response + } finally { + clearTimeout(abortTimeoutId) + if (rejectTimeoutId) { + clearTimeout(rejectTimeoutId) + } + } +} + +// ----------------------------------------------------------------------------- +// Envelope parsing +// ----------------------------------------------------------------------------- + +interface ParseEnvelopeOptions { + endpoint: string + allow404?: boolean +} + +function tryParseJson(text: string): { ok: true; value: unknown } | { ok: false } { + if (text.length === 0) return { ok: false } + try { + return { ok: true, value: JSON.parse(text) } + } catch { + return { ok: false } + } +} + +function isEnvelopeShape(value: unknown): value is CloudflareAPIResponse { + if (!value || typeof value !== 'object') return false + const record = value as Record + return typeof record.success === 'boolean' + && Array.isArray(record.errors) + && Array.isArray(record.messages) + && 'result' in record +} + +/** + * Some Cloudflare API surfaces (notably the v4 Queues list endpoint) + * return a bare `{ result, result_info? }` body without the standard + * `{ success, errors, messages }` envelope. When the HTTP response is + * a 2xx, treat this as a successful envelope so callers don't fail on + * a perfectly valid payload. + */ +function coerceBareResultEnvelope( + value: unknown, + response: Response +): CloudflareAPIResponse | null { + if (!response.ok) return null + if (!value || typeof value !== 'object') return null + const record = value as Record + if (!('result' in record)) return null + return { + success: true, + errors: [], + messages: [], + result: record.result, + ...(record.result_info && typeof record.result_info === 'object' + ? { result_info: record.result_info as CloudflareAPIResponse['result_info'] } + : {}) + } +} + +function envelopeFailureError( + response: Response, + envelope: CloudflareAPIResponse, + endpoint: string +): CloudflareAPIError { + const first = envelope.errors[0] + const message = first + ? `Cloudflare ${endpoint} failed (${first.code}): ${first.message}` + : `Cloudflare ${endpoint} failed` + return new CloudflareAPIError(message, response.status, envelope.errors) +} + +/** + * Read a response body once (as text) and decode the Cloudflare v4 envelope + * without throwing on `success: false`. Returns `null` when a 404 is + * explicitly allowed via `allow404: true`. + * + * Throws `CloudflareAPIError` when the body is not valid JSON or does not + * match the expected v4 envelope shape. + */ +async function decodeCloudflareEnvelope( + response: Response, + opts: ParseEnvelopeOptions +): Promise | null> { + if (opts.allow404 === true && response.status === 404) { + return null + } + + const text = await response.text() + const parsed = tryParseJson(text) + + if (!parsed.ok) { + throw new CloudflareAPIError( + `Cloudflare API returned an invalid JSON response. Body: ${truncateBody(text)}`, + response.status, + [] + ) + } + + if (!isEnvelopeShape(parsed.value)) { + const coerced = coerceBareResultEnvelope(parsed.value, response) + if (coerced) { + return coerced as CloudflareAPIResponse + } + throw new CloudflareAPIError( + `Cloudflare ${opts.endpoint} returned a non-envelope JSON response. Body: ${truncateBody(text)}`, + response.status, + [] + ) + } + + return parsed.value as CloudflareAPIResponse +} + +function truncateBody(text: string, max = 500): string { + const trimmed = text.trim() + if (trimmed.length <= max) return trimmed + return `${trimmed.slice(0, max)}โ€ฆ[truncated, total ${trimmed.length} chars]` +} + +/** + * Canonical Cloudflare v4 envelope parser. + * + * - Reads the response body exactly once (text -> tryParseJson). + * - Enforces the `{ success, errors, messages, result }` shape. + * - Throws `CloudflareAPIError` when `success === false`, surfacing + * `errors[0].code` and `errors[0].message`. + * - Treats `404` as success when `allow404: true` is passed, returning + * `null as T` without reading the body. + */ +export async function parseCloudflareEnvelope( + response: Response, + opts: ParseEnvelopeOptions +): Promise { + const envelope = await decodeCloudflareEnvelope(response, opts) + if (envelope === null) { + return null as T + } + + if (!envelope.success) { + throw envelopeFailureError(response, envelope, opts.endpoint) + } + + return envelope.result +} + +/** + * Raw JSON parser for Cloudflare endpoints that do not use the v4 envelope. + * Reads the body once as text and parses it as JSON. + */ +export async function parseRawJson( + response: Response, + opts: { endpoint: string } +): Promise { + const text = await response.text() + const parsed = tryParseJson(text) + if (!parsed.ok) { + throw new CloudflareAPIError( + `Cloudflare ${opts.endpoint} returned an invalid JSON response.`, + response.status, + [] + ) + } + return parsed.value as T +} + +// ----------------------------------------------------------------------------- +// Auth session +// ----------------------------------------------------------------------------- + +export interface CloudflareAuthSession { + getAuthHeader(forceRefresh?: boolean): Promise + invalidate(): void +} + +interface CreateAuthSessionOptions { + accountId?: string + tokenProvider?: (forceRefresh: boolean) => Promise + onInvalidate?: () => void +} + +/** + * Create a small auth session abstraction that centralises "which token are + * we sending" for every request. The session owns token resolution and + * invalidation so request code does not call `getApiToken` directly. + */ +export function createCloudflareAuthSession(opts: CreateAuthSessionOptions = {}): CloudflareAuthSession { + const provider = opts.tokenProvider ?? ((forceRefresh: boolean) => getApiToken(forceRefresh)) + const onInvalidate = opts.onInvalidate ?? invalidateToken + + return { + async getAuthHeader(forceRefresh = false) { + const token = await provider(forceRefresh) + if (!token) { + throw new AuthenticationError() + } + return `Bearer ${token}` + }, + invalidate() { + onInvalidate() + } + } +} + +const defaultAuthSession = createCloudflareAuthSession({}) + +async function resolveAuthHeader(options?: APIClientOptions, forceRefresh = false): Promise { + if (options?.token) { + return `Bearer ${options.token}` + } + return defaultAuthSession.getAuthHeader(forceRefresh) +} + +// ----------------------------------------------------------------------------- +// API Client +// ----------------------------------------------------------------------------- + +export interface APIClientOptions { + /** Override the API token (instead of auto-detecting) */ + token?: string + /** Request timeout in ms (default: 10000) */ + timeout?: number +} + +interface CloudflareJsonRequestOptions { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + body?: unknown + allowAuthRetry?: boolean +} + +/** + * Check if a decoded envelope represents an auth failure worth retrying. + */ +function isAuthError(response: Response, envelope: CloudflareAPIResponse): boolean { + if (response.status === 401) return true + if (!envelope.success && envelope.errors?.some((e) => + e.code === 10000 || // Auth error code + e.message?.toLowerCase().includes('authentication') || + e.message?.toLowerCase().includes('token') + )) { + return true + } + return false +} + +/** + * Execute a Cloudflare JSON request and return the decoded envelope without + * throwing on `success: false` so callers can inspect and retry on auth + * errors before surfacing failures. + * + * Bursts of resource creates (KV, D1, Queues during deploy) can hit + * Cloudflare's per-account rate limits, and edge proxies occasionally return + * 5xx. We retry network errors and 429/5xx responses with bounded + * exponential backoff before surfacing the failure. + */ +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]) +const RETRY_BASE_DELAY_MS = 250 +const RETRY_MAX_ATTEMPTS = 4 + +function shouldRetryResponse(response: Response): boolean { + return RETRYABLE_STATUS.has(response.status) +} + +function backoffDelayMs(attempt: number, retryAfterHeader: string | null): number { + if (retryAfterHeader) { + const parsed = Number(retryAfterHeader) + if (Number.isFinite(parsed) && parsed > 0) { + return Math.min(parsed * 1000, 10_000) + } + } + const base = RETRY_BASE_DELAY_MS * 2 ** attempt + const jitter = Math.random() * RETRY_BASE_DELAY_MS + return Math.min(base + jitter, 10_000) +} + +async function requestCloudflareJson( + path: string, + request: CloudflareJsonRequestOptions, + options?: APIClientOptions +): Promise<{ + response: Response + envelope: CloudflareAPIResponse +}> { + const endpoint = `${request.method} ${path}` + + const makeRequest = async (forceRefresh: boolean) => { + const authorization = await resolveAuthHeader(options, forceRefresh) + const headers = new Headers({ + 'Authorization': authorization, + 'Content-Type': 'application/json' + }) + const response = await fetchWithTimeout(`${API_BASE}${path}`, { + method: request.method, + headers, + ...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}) + }, options?.timeout ?? DEFAULT_TIMEOUT) + const envelope = await decodeCloudflareEnvelope(response, { endpoint }) + // allow404 is not set, so envelope is non-null. + return { response, envelope: envelope as CloudflareAPIResponse } + } + + let result: { response: Response; envelope: CloudflareAPIResponse } | null = null + let lastError: unknown = null + for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) { + try { + result = await makeRequest(false) + } catch (error) { + lastError = error + if (attempt < RETRY_MAX_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, backoffDelayMs(attempt, null))) + continue + } + throw error + } + const current = result + if (!shouldRetryResponse(current.response) || attempt === RETRY_MAX_ATTEMPTS - 1) { + break + } + await new Promise((r) => setTimeout(r, backoffDelayMs(attempt, current.response.headers.get('retry-after')))) + } + + if (!result) { + throw lastError ?? new Error(`Cloudflare API request failed: ${endpoint}`) + } + + if (request.allowAuthRetry === true && isAuthError(result.response, result.envelope) && !options?.token) { + defaultAuthSession.invalidate() + result = await makeRequest(true) + } + + return result +} + +async function requestCloudflareResult( + path: string, + request: CloudflareJsonRequestOptions, + options?: APIClientOptions +): Promise { + const { response, envelope } = await requestCloudflareJson(path, request, options) + if (!envelope.success) { + throw envelopeFailureError(response, envelope, `${request.method} ${path}`) + } + return envelope.result +} + +/** + * Make a GET request to the Cloudflare API + * Automatically retries with a fresh token on auth failure + */ +export async function apiGet( + path: string, + options?: APIClientOptions +): Promise { + return requestCloudflareResult(path, { + method: 'GET', + allowAuthRetry: true + }, options) +} + +/** + * Make a POST request to the Cloudflare API + */ +export async function apiPost( + path: string, + body: unknown, + options?: APIClientOptions +): Promise { + return requestCloudflareResult(path, { + method: 'POST', + body + }, options) +} + +/** + * Make a PUT request to the Cloudflare API + */ +export async function apiPut( + path: string, + body: unknown, + options?: APIClientOptions +): Promise { + return requestCloudflareResult(path, { + method: 'PUT', + body + }, options) +} + +/** + * Make a PATCH request to the Cloudflare API + */ +export async function apiPatch( + path: string, + body: unknown, + options?: APIClientOptions +): Promise { + return requestCloudflareResult(path, { + method: 'PATCH', + body + }, options) +} + +/** + * Make a DELETE request to the Cloudflare API + */ +export async function apiDelete( + path: string, + options?: APIClientOptions +): Promise { + return requestCloudflareResult(path, { + method: 'DELETE' + }, options) +} + +export interface PaginationOptions extends APIClientOptions { + /** + * Hard cap on number of pages fetched. Defaults to 500 (i.e. 25 000 items + * at the default page size). Raising this is safe; the loop also breaks + * on exhausted `total_pages` / `total_count` / cursor exhaustion. A + * warning is emitted when the cap is actually hit so callers notice when + * results may be silently truncated. + */ + maxPages?: number + /** + * Items per page. Defaults to 50 (Cloudflare's conservative default). + */ + pageSize?: number +} + +const DEFAULT_PAGINATION_MAX_PAGES = 500 +const DEFAULT_PAGINATION_PAGE_SIZE = 50 + +/** + * Make a paginated GET request, fetching all pages. + * + * Emits a `console.warn` and throws `PaginationCapExceededError` when the + * explicit `maxPages` cap is reached with more data still available, to + * prevent silent truncation (which would otherwise cause callers to + * re-create already-existing resources as if they did not exist). Pass a + * higher `maxPages` when the caller knows the resource count can exceed the + * default cap. + */ +export async function apiGetAll( + path: string, + options?: PaginationOptions +): Promise { + const results: T[] = [] + let page = 1 + let cursor: string | undefined + const perPage = options?.pageSize ?? DEFAULT_PAGINATION_PAGE_SIZE + const maxPages = options?.maxPages ?? DEFAULT_PAGINATION_MAX_PAGES + const seenCursors = new Set() + + const extractPaginatedItems = (result: unknown): T[] => { + if (Array.isArray(result)) { + return result as T[] + } + + if (!result || typeof result !== 'object') { + throw new Error('Expected paginated Cloudflare API result to be an array or an object containing an array.') + } + + const arrayEntries = Object.entries(result).filter(([, value]) => Array.isArray(value)) + if (arrayEntries.length !== 1) { + throw new Error('Expected paginated Cloudflare API result object to contain exactly one array property.') + } + + return arrayEntries[0][1] as T[] + } + + while (page <= maxPages) { + const separator = path.includes('?') ? '&' : '?' + const pagedPath = cursor + ? `${path}${separator}cursor=${encodeURIComponent(cursor)}&per_page=${perPage}` + : `${path}${separator}page=${page}&per_page=${perPage}` + + const { response, envelope } = await requestCloudflareJson>(pagedPath, { + method: 'GET', + allowAuthRetry: true + }, options) + + if (!envelope.success) { + throw envelopeFailureError(response, envelope, `GET ${pagedPath}`) + } + + const pageResults = extractPaginatedItems(envelope.result) + results.push(...pageResults) + + // Stop conditions: + // 1. No result_info at all + // 2. No results returned (empty page) + // 3. We've fetched all items based on total_count + // 4. total_pages is defined and we've reached it + if (!envelope.result_info) { + break + } + + // If we got no results, we're done + if (pageResults.length === 0) { + break + } + + const nextCursor = envelope.result_info.cursor?.trim() + if (nextCursor) { + if (seenCursors.has(nextCursor)) { + break + } + + seenCursors.add(nextCursor) + cursor = nextCursor + continue + } + + if (cursor) { + break + } + + // If we have total_count, check if we've got all items + if (envelope.result_info.total_count !== undefined) { + if (results.length >= envelope.result_info.total_count) { + break + } + } + + // If we have total_pages, check if we've reached it + if (envelope.result_info.total_pages !== undefined) { + if (page >= envelope.result_info.total_pages) { + break + } + } + + page++ + } + + // If the loop exited because we hit `maxPages` rather than because the + // data source was exhausted, warn so the caller does not treat the + // partial result as authoritative (e.g. "create this KV namespace + // because the listing didn't include it"). Only warn when the page + // counter actually exceeded the cap and the last page we fetched was + // full (a strong signal that more data exists beyond the cap). + if (page > maxPages) { + const lastPageLikelyFull = results.length % perPage === 0 && results.length > 0 + if (lastPageLikelyFull) { + console.warn( + `[devflare] apiGetAll capped at ${maxPages} pages for ${path}. Results may be truncated; pass { maxPages } to raise the cap.` + ) + } + } + + return results +} + +// ----------------------------------------------------------------------------- +// KV-Specific Helpers +// ----------------------------------------------------------------------------- +// Cloudflare KV "values" endpoints are NOT JSON envelopes on success โ€” they +// return raw text/binary. Error responses still return a v4 envelope, which +// we decode through the canonical parser. + +async function requestKVValue( + accountId: string, + namespaceId: string, + key: string, + request: { + method: 'GET' + } | { + method: 'PUT' + value: string + } | { + method: 'DELETE' + }, + options?: APIClientOptions +): Promise { + const authorization = await resolveAuthHeader(options) + + const encodedKey = encodeURIComponent(key) + const url = `${API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodedKey}` + + return fetchWithTimeout(url, { + method: request.method, + headers: { + 'Authorization': authorization, + ...(request.method === 'PUT' ? { 'Content-Type': 'text/plain' } : {}) + }, + ...(request.method === 'PUT' ? { body: request.value } : {}) + }, options?.timeout ?? DEFAULT_TIMEOUT) +} + +/** + * Surface a failed KV-value response as a typed CloudflareAPIError by + * decoding the envelope the error path emits. + */ +async function throwKVValueError(response: Response, endpoint: string): Promise { + // parseCloudflareEnvelope throws CloudflareAPIError either on !success or + // when the body is not a valid envelope. + await parseCloudflareEnvelope(response, { endpoint }) + // If the body somehow parsed as a successful envelope on a non-ok + // response, still surface a typed error. + throw new CloudflareAPIError(`Cloudflare ${endpoint} failed`, response.status, []) +} + +/** + * Read a KV value (raw text response, not JSON envelope) + * Returns null if key doesn't exist (404) + */ +export async function kvGet( + accountId: string, + namespaceId: string, + key: string, + options?: APIClientOptions +): Promise { + const response = await requestKVValue(accountId, namespaceId, key, { + method: 'GET' + }, options) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + await throwKVValueError(response, 'KV read') + } + + return response.text() +} + +/** + * Write a KV value (raw text body, not JSON) + */ +export async function kvPut( + accountId: string, + namespaceId: string, + key: string, + value: string, + options?: APIClientOptions +): Promise { + const response = await requestKVValue(accountId, namespaceId, key, { + method: 'PUT', + value + }, options) + + if (!response.ok) { + await throwKVValueError(response, 'KV write') + } +} + +/** + * Delete a KV key + * Treats 404 as success (key already absent). + */ +export async function kvDelete( + accountId: string, + namespaceId: string, + key: string, + options?: APIClientOptions +): Promise { + const response = await requestKVValue(accountId, namespaceId, key, { + method: 'DELETE' + }, options) + + if (response.status === 404) { + return + } + + if (!response.ok) { + await throwKVValueError(response, 'KV delete') + } +} diff --git a/packages/devflare/src/cloudflare/auth.ts b/packages/devflare/src/cloudflare/auth.ts new file mode 100644 index 0000000..f758e8d --- /dev/null +++ b/packages/devflare/src/cloudflare/auth.ts @@ -0,0 +1,260 @@ +// ============================================================================= +// Wrangler Auth Extraction +// ============================================================================= +// Extracts usable API token from wrangler's config or via `wrangler auth token` +// +// Strategy: +// 1. Read oauth_token + expiration_time from wrangler's config file (fast) +// 2. If token is valid (not expired), use it directly +// 3. If expired, call `wrangler auth token` to refresh (slow but necessary) +// ============================================================================= + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readFileSync, existsSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { parse as parseToml } from 'smol-toml' +import type { WranglerAuth } from './types' + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +const WRANGLER_CONFIG_FILE = 'config/default.toml' + +// Buffer before expiration to trigger refresh (5 minutes) +const EXPIRY_BUFFER_MS = 5 * 60 * 1000 + +// In-memory cache (for repeated calls within same process) +let cachedToken: string | null = null +let cacheExpiresAt = 0 + +/** + * Invalidate the cached token (call when API returns auth error) + * This forces the next getApiToken() call to refresh via wrangler + */ +export function invalidateToken(): void { + cachedToken = null + cacheExpiresAt = 0 +} + +// ----------------------------------------------------------------------------- +// Path Resolution +// ----------------------------------------------------------------------------- + +/** + * Get possible paths to wrangler's config file + * Returns array of paths to check, in order of priority + */ +function getWranglerConfigPaths(): string[] { + const paths: string[] = [] + + // Windows: %APPDATA%\xdg.config\.wrangler + if (process.platform === 'win32' && process.env.APPDATA) { + paths.push(join(process.env.APPDATA, 'xdg.config', '.wrangler', WRANGLER_CONFIG_FILE)) + } + + // XDG_CONFIG_HOME (cross-platform) + if (process.env.XDG_CONFIG_HOME) { + paths.push(join(process.env.XDG_CONFIG_HOME, '.wrangler', WRANGLER_CONFIG_FILE)) + } + + // Standard Unix location: ~/.wrangler + paths.push(join(homedir(), '.wrangler', WRANGLER_CONFIG_FILE)) + + return paths +} + +/** + * Get the path to wrangler's config file (first existing path) + */ +export function getWranglerConfigPath(): string | null { + const paths = getWranglerConfigPaths() + + for (const path of paths) { + if (existsSync(path)) { + return path + } + } + + return null +} + +/** + * Check if wrangler config file exists + */ +export function hasWranglerConfig(): boolean { + return getWranglerConfigPath() !== null +} + +/** + * Parse TOML config file (wrangler's stored OAuth state). + * + * Uses a real TOML parser (`smol-toml`) so that nested sections, escapes, + * and other TOML 1.0 constructs are handled correctly. We deliberately + * read only the implicit root section: any keys nested under a `[section]` + * header are ignored to keep this resilient against future wrangler + * additions that put new sections in the same file. If the parser throws + * (corrupt file, partial write), the caller falls back to the slower + * `bunx wrangler auth token` shell-out path. + */ +function parseSimpleToml(content: string): Record { + let parsed: Record + try { + parsed = parseToml(content) as Record + } catch { + return {} + } + + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + // Only keep root-level scalars; nested tables/arrays are skipped. + if (typeof value === 'string') { + result[key] = value + } else if (typeof value === 'number' || typeof value === 'boolean') { + result[key] = String(value) + } + } + return result +} + +/** + * Extract OAuth token info from wrangler's stored configuration (sync) + * Returns token and expiration time if available + */ +function readWranglerConfig(): { token: string; expiresAt: Date } | null { + const configPath = getWranglerConfigPath() + + if (!configPath || !existsSync(configPath)) { + return null + } + + try { + const content = readFileSync(configPath, 'utf-8') + const config = parseSimpleToml(content) + + const token = config.oauth_token + const expirationTime = config.expiration_time + + if (token && expirationTime) { + return { + token, + expiresAt: new Date(expirationTime) + } + } + + return null + } catch { + return null + } +} + +/** + * Extract OAuth token info from wrangler's stored configuration + */ +export async function getWranglerAuth(): Promise { + const config = readWranglerConfig() + if (!config) return null + + const configPath = getWranglerConfigPath() + if (!configPath) return null + + try { + const content = readFileSync(configPath, 'utf-8') + const parsed = parseSimpleToml(content) + + return { + oauthToken: parsed.oauth_token, + refreshToken: parsed.refresh_token, + expiresAt: config.expiresAt + } + } catch { + return null + } +} + +/** + * Refresh the token via `wrangler auth token` command + * This is slow (~2-3s) so only called when necessary + */ +function refreshWranglerToken(): string | null { + try { + // Use bunx for faster startup than npx + const result = execSync('bunx wrangler auth token', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000 + }) + + // Extract token from output (last non-empty line) + const lines = result.trim().split(/\r?\n/).filter((l) => l.trim().length > 0) + const token = lines[lines.length - 1]?.trim() + + if (token && token.length >= 20 && !token.includes('wrangler') && !token.includes('โ›…')) { + return token + } + + return null + } catch { + return null + } +} + +/** + * Get the API token to use for Cloudflare API requests + * + * Strategy (fast path first): + * 1. Check in-memory cache (unless forceRefresh) + * 2. Check CLOUDFLARE_API_TOKEN env var + * 3. Read token from wrangler config (if not expired) + * 4. Call `wrangler auth token` to refresh (slow, only when needed) + * + * @param forceRefresh - Skip cache and force a refresh via wrangler + */ +export async function getApiToken(forceRefresh = false): Promise { + // 1. Check in-memory cache (unless forcing refresh) + if (!forceRefresh && cachedToken && Date.now() < cacheExpiresAt) { + return cachedToken + } + + // 2. Check environment variable (always takes priority, can't be refreshed) + const envToken = process.env.CLOUDFLARE_API_TOKEN + if (envToken) { + return envToken + } + + // 3. Try to read from wrangler config (fast path, unless forcing refresh) + if (!forceRefresh) { + const config = readWranglerConfig() + if (config) { + const now = Date.now() + const expiresAt = config.expiresAt.getTime() + + // If token is valid (with buffer), use it directly + if (now < expiresAt - EXPIRY_BUFFER_MS) { + cachedToken = config.token + cacheExpiresAt = expiresAt - EXPIRY_BUFFER_MS + return config.token + } + } + } + + // 4. Token expired, missing, or forced refresh - call wrangler (slow path) + const refreshedToken = refreshWranglerToken() + if (refreshedToken) { + // Cache for 5 minutes (wrangler will have updated the config file) + cachedToken = refreshedToken + cacheExpiresAt = Date.now() + EXPIRY_BUFFER_MS + return refreshedToken + } + + return null +} + +/** + * Check if we have valid authentication + */ +export async function isAuthenticated(): Promise { + const token = await getApiToken() + return token !== null +} diff --git a/packages/devflare/src/cloudflare/index.ts b/packages/devflare/src/cloudflare/index.ts new file mode 100644 index 0000000..9e6e796 --- /dev/null +++ b/packages/devflare/src/cloudflare/index.ts @@ -0,0 +1,407 @@ +// ============================================================================= +// Devflare Cloudflare Module +// ============================================================================= +// Main entry point for `import { account } from 'devflare/cloudflare'` +// Provides account info, service status, usage tracking, and limit enforcement +// ============================================================================= + +import { + getAccounts, + getPrimaryAccount, + getAccountById, + getAccountSummary, + getWorkersSubdomain, + listWorkers, + renameWorker, + deleteWorker, + listWorkerVersions, + getWorkerVersionDetail, + listWorkerDeployments, + listKVNamespaces, + listD1Databases, + createD1Database, + queryD1Database, + rawD1DatabaseQuery, + listR2Buckets, + listVectorizeIndexes, + listAIModels, + getServiceStatus, + getAllServiceStatus, + hasService, + checkAuth +} from './account' + +import { + getUsage, + recordUsage, + resetUsage, + getLimits, + setLimits, + setLimitsEnabled, + isWithinLimits, + getUsageSummary, + getAllUsageSummaries, + canProceedWithTest, + recordTestUsage, + shouldSkip +} from './usage' + +import { + getGlobalDefaultAccountId, + setGlobalDefaultAccountId, + getWorkspaceAccountId, + setWorkspaceAccountId, + getEffectiveAccountId, + clearGlobalDefaultAccountId +} from './preferences' + +import { getApiToken, isAuthenticated, getWranglerAuth, hasWranglerConfig, invalidateToken } from './auth' +import { CloudflareAPIError, AuthenticationError, type APIClientOptions } from './api' +import { + createAccountOwnedAPIToken, + deleteAccountOwnedAPIToken, + listAccountOwnedAPITokens, + listAccountTokenPermissionGroups, + normalizeDevflareTokenName +} from './tokens' +import { + ensurePreviewRegistry, + getPreviewRegistryContext, + listTrackedRegistryState, + listTrackedPreviewRecords, + listTrackedPreviewScopeRecords, + listTrackedDeploymentRecords, + reconcilePreviewRegistry, + cleanupPreviewRegistry, + retirePreviewRegistry, + DEVFLARE_PREVIEW_REGISTRY_DATABASE +} from './preview-registry' + +export { + devflareAccountRecordSchema, + createDevflareAccountRecordSchema, + devflareRecordSourceSchema, + devflarePreviewStatusSchema, + devflarePreviewScopeStatusSchema, + devflareDeploymentChannelSchema, + devflareDeploymentStatusSchema, + devflarePreviewRecordSchema, + devflarePreviewScopeRecordSchema, + devflareDeploymentRecordSchema, + devflareAccountLayerRecordSchema +} from './registry-schema' + +// ----------------------------------------------------------------------------- +// Account API +// ----------------------------------------------------------------------------- + +/** + * Main account API object + * + * Usage: + * ```ts + * import { account } from 'devflare/cloudflare' + * + * // Check authentication + * const isLoggedIn = await account.isAuthenticated() + * + * // Get primary account + * const primary = await account.getPrimaryAccount() + * + * // List resources (requires accountId) + * const workers = await account.workers(accountId) + * + * // Check usage limits before testing + * const { allowed } = await account.canProceedWithTest(accountId, 'ai') + * ``` + */ +export const account = { + // ------------------------------------------------------------------------- + // Authentication + // ------------------------------------------------------------------------- + + /** Check if user is authenticated with Cloudflare */ + isAuthenticated, + + /** Check if wrangler config file exists */ + hasWranglerConfig, + + /** Get the API token (from env or wrangler config) */ + getApiToken, + + /** Get full wrangler auth info */ + getWranglerAuth, + + // ------------------------------------------------------------------------- + // Account Info + // ------------------------------------------------------------------------- + + /** Get all accounts the user has access to */ + getAccounts, + + /** Get the primary (first) account */ + getPrimaryAccount, + + /** Get account by ID */ + getAccountById, + + /** Get comprehensive account summary with all services */ + getAccountSummary, + + // ------------------------------------------------------------------------- + // Resource Listing + // ------------------------------------------------------------------------- + + /** List all Workers scripts */ + workers: listWorkers, + + /** Rename an existing Worker */ + renameWorker, + + /** Delete a Worker script */ + deleteWorker, + + /** List all Worker versions for a script */ + workerVersions: listWorkerVersions, + + /** Get a single Worker version detail */ + workerVersion: getWorkerVersionDetail, + + /** List all Worker deployments for a script */ + workerDeployments: listWorkerDeployments, + + /** Get the account workers.dev subdomain */ + workersSubdomain: getWorkersSubdomain, + + /** List all KV namespaces */ + kv: listKVNamespaces, + + /** List all D1 databases */ + d1: listD1Databases, + + /** Create a D1 database */ + createD1Database, + + /** Execute a D1 query */ + queryD1Database, + + /** Execute a D1 raw query */ + rawD1DatabaseQuery, + + /** List all R2 buckets */ + r2: listR2Buckets, + + /** List all Vectorize indexes */ + vectorize: listVectorizeIndexes, + + /** List available AI models */ + ai: listAIModels, + + // ------------------------------------------------------------------------- + // Service Status + // ------------------------------------------------------------------------- + + /** Get status of a specific service */ + getServiceStatus, + + /** Get status of all services */ + getAllServiceStatus, + + /** Check if a service is available */ + hasService, + + // ------------------------------------------------------------------------- + // Usage Tracking + // ------------------------------------------------------------------------- + + /** Get usage for a service on a date */ + getUsage, + + /** Record usage for a service */ + recordUsage, + + /** Reset usage counter for a service */ + resetUsage, + + /** Get all usage summaries */ + getAllUsageSummaries, + + /** Get usage summary for a service */ + getUsageSummary, + + // ------------------------------------------------------------------------- + // Limits + // ------------------------------------------------------------------------- + + /** Get current usage limits */ + getLimits, + + /** Update usage limits */ + setLimits, + + /** Enable or disable limit enforcement */ + setLimitsEnabled, + + /** Check if within limits for a service */ + isWithinLimits, + + // ------------------------------------------------------------------------- + // Test Helpers + // ------------------------------------------------------------------------- + + /** Check if a test can proceed (within limits) */ + canProceedWithTest, + + /** Record test usage after successful test */ + recordTestUsage, + + /** + * Check if tests for a service should be skipped + * Returns true if tests should be SKIPPED (not authenticated, no account, or limits exceeded) + * Automatically logs the skip reason to console. + * + * Usage: `const skipAI = await account.shouldSkip('ai')` + */ + shouldSkip, + + // ------------------------------------------------------------------------- + // Preferences + // ------------------------------------------------------------------------- + + /** Get the global default account ID */ + getGlobalDefaultAccountId, + + /** Set the global default account ID */ + setGlobalDefaultAccountId, + + /** Get the workspace account ID from package.json */ + getWorkspaceAccountId, + + /** Set the workspace account ID in package.json */ + setWorkspaceAccountId, + + /** Get the effective account ID (workspace > global > primary) */ + getEffectiveAccountId, + + /** Clear the global default account ID */ + clearGlobalDefaultAccountId, + + /** List permission groups available for account-owned API tokens */ + listAccountTokenPermissionGroups, + + /** List account-owned API tokens */ + listAccountOwnedAPITokens, + + /** Create a new account-owned API token */ + createAccountOwnedAPIToken, + + /** Delete an account-owned API token */ + deleteAccountOwnedAPIToken, + + /** Normalize a token name to the managed devflare- prefix */ + normalizeDevflareTokenName, + + /** Default D1 database name used by the Devflare preview registry */ + previewRegistryDatabase: DEVFLARE_PREVIEW_REGISTRY_DATABASE, + + /** Ensure the Devflare preview registry D1 database exists */ + ensurePreviewRegistry, + + /** Get the current preview registry context */ + getPreviewRegistryContext, + + /** List tracked preview records from the Devflare registry */ + listTrackedPreviewRecords, + + /** List tracked preview, scope, and deployment records from the Devflare registry */ + listTrackedRegistryState, + + /** List tracked preview-scope records from the Devflare registry */ + listTrackedPreviewScopeRecords, + + /** List tracked deployment records from the Devflare registry */ + listTrackedDeploymentRecords, + + /** Reconcile the Devflare preview registry with live Cloudflare state */ + reconcilePreviewRegistry, + + /** Clean up stale Devflare preview registry records */ + cleanupPreviewRegistry, + + /** Retire a tracked preview, scope, and preview deployment immediately */ + retirePreviewRegistry +} as const + +// ----------------------------------------------------------------------------- +// Type Exports +// ----------------------------------------------------------------------------- + +export type { + AccountInfo, + CloudflareAccount, + CloudflareService, + ServiceStatus, + WorkerInfo, + WorkerVersionInfo, + WorkerDeploymentInfo, + KVNamespaceInfo, + D1DatabaseInfo, + D1QueryParameter, + D1QueryResult, + D1RawQueryResult, + R2BucketInfo, + VectorizeIndexInfo, + AIModelInfo, + UsageRecord, + UsageLimits, + UsageSummary, + WranglerAuth, + AccountTokenPermissionGroup, + AccountOwnedAPIToken, + AccountOwnedAPITokenDeleteResult, + AccountOwnedAPITokenPermissionGroup, + AccountOwnedAPITokenPolicy +} from './types' + +export type { RenamedWorkerInfo } from './account' + +export type { + CloudflareUserId, + DevflareAccountRecord, + DevflareRecordSource, + DevflarePreviewStatus, + DevflarePreviewScopeStatus, + DevflareDeploymentChannel, + DevflareDeploymentStatus, + DevflarePreviewRecord, + DevflarePreviewScopeRecord, + DevflareDeploymentRecord, + DevflareAccountLayerRecord +} from './registry-schema' + +export type { + PreviewRegistryContext, + ReconcilePreviewRegistryResult, + CleanupPreviewRegistryResult, + RetirePreviewRegistryResult +} from './preview-registry' + +export { + ensurePreviewRegistry, + getPreviewRegistryContext, + listTrackedRegistryState, + listTrackedPreviewRecords, + listTrackedPreviewScopeRecords, + listTrackedDeploymentRecords, + reconcilePreviewRegistry, + cleanupPreviewRegistry, + retirePreviewRegistry, + DEVFLARE_PREVIEW_REGISTRY_DATABASE +} from './preview-registry' + +// ----------------------------------------------------------------------------- +// Error Exports +// ----------------------------------------------------------------------------- + +export { CloudflareAPIError, AuthenticationError } +export type { APIClientOptions } diff --git a/packages/devflare/src/cloudflare/known-permission-group-ids.generated.ts b/packages/devflare/src/cloudflare/known-permission-group-ids.generated.ts new file mode 100644 index 0000000..318ff8b --- /dev/null +++ b/packages/devflare/src/cloudflare/known-permission-group-ids.generated.ts @@ -0,0 +1,32 @@ +// ============================================================================= +// AUTO-GENERATED FILE โ€” Do not edit by hand. +// +// Regenerate with: +// bun run --cwd packages/devflare refresh-permission-groups +// +// Source of truth: +// GET /accounts/:id/tokens/permission_groups (Cloudflare API) +// +// Each entry maps a Devflare symbolic permission-group name to the +// authoritative Cloudflare permission-group UUID, or `null` when no +// verified UUID is known yet (in which case `tokens.ts` falls back to +// exact display-name matching with a console.warn). +// ============================================================================= + +export const KNOWN_PERMISSION_GROUP_IDS_DATA: { + WORKERS_SCRIPTS_WRITE: string | null + WORKERS_SCRIPTS_READ: string | null + ACCOUNT_SETTINGS_READ: string | null + WORKERS_KV_STORAGE_WRITE: string | null + WORKERS_KV_STORAGE_READ: string | null + ACCOUNT_API_TOKENS_WRITE: string | null + ACCOUNT_API_TOKENS_READ: string | null +} = { + WORKERS_SCRIPTS_WRITE: null, + WORKERS_SCRIPTS_READ: null, + ACCOUNT_SETTINGS_READ: null, + WORKERS_KV_STORAGE_WRITE: null, + WORKERS_KV_STORAGE_READ: null, + ACCOUNT_API_TOKENS_WRITE: null, + ACCOUNT_API_TOKENS_READ: null +} diff --git a/packages/devflare/src/cloudflare/kv-namespace.ts b/packages/devflare/src/cloudflare/kv-namespace.ts new file mode 100644 index 0000000..4d41a90 --- /dev/null +++ b/packages/devflare/src/cloudflare/kv-namespace.ts @@ -0,0 +1,28 @@ +import { apiGetAll, apiPost, type APIClientOptions } from './api' +import type { KVNamespace } from './types' + +export const DEVFLARE_KV_NAMESPACE_TITLE = 'devflare-usage' + +export async function getOrCreateNamedKVNamespace( + accountId: string, + title: string = DEVFLARE_KV_NAMESPACE_TITLE, + options?: APIClientOptions +): Promise { + const namespaces = await apiGetAll( + `/accounts/${accountId}/storage/kv/namespaces`, + options + ) + + const existing = namespaces.find((namespace) => namespace.title === title) + if (existing) { + return existing.id + } + + const created = await apiPost( + `/accounts/${accountId}/storage/kv/namespaces`, + { title }, + options + ) + + return created.id +} diff --git a/packages/devflare/src/cloudflare/preferences.ts b/packages/devflare/src/cloudflare/preferences.ts new file mode 100644 index 0000000..df74139 --- /dev/null +++ b/packages/devflare/src/cloudflare/preferences.ts @@ -0,0 +1,323 @@ +// ============================================================================= +// Account Preferences Module +// ============================================================================= +// Stores and retrieves account preferences (global default, etc.) +// +// Storage Locations: +// - Global default: Stored in devflare KV namespace in user's Cloudflare account +// AND cached locally in ~/.devflare/preferences.json +// - Workspace default: Stored in package.json as "devflare.accountId" +// ============================================================================= + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs' +import { kvDelete, kvGet, kvPut } from './api' +import { DEVFLARE_KV_NAMESPACE_TITLE, getOrCreateNamedKVNamespace } from './kv-namespace' + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +const GLOBAL_ACCOUNT_KEY = 'settings:defaultAccountId' +const LOCAL_CACHE_DIR = '.devflare' +const LOCAL_CACHE_FILE = 'preferences.json' + +// ----------------------------------------------------------------------------- +// Atomic file writes +// ----------------------------------------------------------------------------- + +/** + * Write a file atomically by writing to a temp sibling and renaming into place. + * Prevents corruption if the process is killed mid-write. + * + * @internal exported for tests only + */ +export function writeFileAtomic(path: string, contents: string): void { + const tmpPath = path + '.tmp-' + process.pid + '-' + Date.now() + writeFileSync(tmpPath, contents, 'utf-8') + try { + renameSync(tmpPath, path) + } catch (error) { + try { + unlinkSync(tmpPath) + } catch { + // best-effort cleanup; ignore + } + throw error + } +} + +// ----------------------------------------------------------------------------- +// Local Cache +// ----------------------------------------------------------------------------- + +interface LocalPreferences { + defaultAccountId?: string + lastUpdated?: string +} + +/** + * Get the path to the local preferences file + */ +function getLocalPreferencesPath(): string { + return join(homedir(), LOCAL_CACHE_DIR, LOCAL_CACHE_FILE) +} + +/** + * Read local preferences from disk + */ +function readLocalPreferences(): LocalPreferences { + const path = getLocalPreferencesPath() + if (!existsSync(path)) { + return {} + } + + try { + const content = readFileSync(path, 'utf-8') + return JSON.parse(content) as LocalPreferences + } catch { + return {} + } +} + +/** + * Write local preferences to disk + */ +function writeLocalPreferences(prefs: LocalPreferences): void { + const path = getLocalPreferencesPath() + const dir = join(homedir(), LOCAL_CACHE_DIR) + + // Ensure directory exists + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileAtomic(path, JSON.stringify(prefs, null, '\t')) +} + +// ----------------------------------------------------------------------------- +// Workspace Preferences (package.json) +// ----------------------------------------------------------------------------- + +interface PackageJson { + devflare?: { + accountId?: string + } + [key: string]: unknown +} + +/** + * Find the nearest package.json (searching upward from cwd) + */ +function findPackageJsonPath(startDir?: string): string | null { + let dir = startDir ?? process.cwd() + + // Walk up the directory tree + while (dir !== join(dir, '..')) { + const pkgPath = join(dir, 'package.json') + if (existsSync(pkgPath)) { + return pkgPath + } + dir = join(dir, '..') + } + + return null +} + +/** + * Read package.json from a path + */ +function readPackageJson(path: string): PackageJson | null { + try { + const content = readFileSync(path, 'utf-8') + return JSON.parse(content) as PackageJson + } catch { + return null + } +} + +/** + * Write package.json to a path + */ +function writePackageJson(path: string, pkg: PackageJson): void { + writeFileAtomic(path, JSON.stringify(pkg, null, '\t') + '\n') +} + +/** + * Get workspace account ID from nearest package.json + */ +export function getWorkspaceAccountId(): string | null { + const pkgPath = findPackageJsonPath() + if (!pkgPath) return null + + const pkg = readPackageJson(pkgPath) + return pkg?.devflare?.accountId ?? null +} + +/** + * Set workspace account ID in nearest package.json + * Creates package.json if it doesn't exist + */ +export function setWorkspaceAccountId(accountId: string): string { + let pkgPath = findPackageJsonPath() + let pkg: PackageJson + + if (pkgPath) { + pkg = readPackageJson(pkgPath) ?? {} + } else { + // Create a new package.json in cwd + pkgPath = join(process.cwd(), 'package.json') + pkg = { + name: 'workspace', + private: true + } + } + + // Ensure devflare object exists + if (!pkg.devflare) { + pkg.devflare = {} + } + + pkg.devflare.accountId = accountId + + writePackageJson(pkgPath, pkg) + + return pkgPath +} + +// ----------------------------------------------------------------------------- +// Cloud KV Storage +// ----------------------------------------------------------------------------- + +/** + * Find or create the devflare-managed KV namespace + * (Reuses the same namespace as usage tracking) + */ + +async function getOrCreatePreferencesNamespace(accountId: string): Promise { + return getOrCreateNamedKVNamespace(accountId, DEVFLARE_KV_NAMESPACE_TITLE) +} + +// ----------------------------------------------------------------------------- +// Global Default Account +// ----------------------------------------------------------------------------- + +/** + * Get the global default account ID + * + * Priority: + * 1. Local cache (fast, no network) + * 2. Cloud KV (if local cache is missing) + * + * Returns null if no default is set + */ +export async function getGlobalDefaultAccountId( + fallbackAccountId: string +): Promise { + // 1. Check local cache first (fast) + const local = readLocalPreferences() + if (local.defaultAccountId) { + return local.defaultAccountId + } + + // 2. Check cloud KV (requires an account to read from) + try { + const namespaceId = await getOrCreatePreferencesNamespace(fallbackAccountId) + const value = await kvGet(fallbackAccountId, namespaceId, GLOBAL_ACCOUNT_KEY) + + if (value) { + // Cache locally for next time + writeLocalPreferences({ + ...local, + defaultAccountId: value, + lastUpdated: new Date().toISOString() + }) + return value + } + } catch (error) { + console.debug('[devflare preferences] cloud KV sync failed:', error instanceof Error ? error.message : String(error)) + } + + return null +} + +/** + * Set the global default account ID + * Saves to both local cache and cloud KV + * + * @param accountId - The account ID to set as default + * @param anyAccountId - Any account ID to use for accessing KV (can be the same) + */ +export async function setGlobalDefaultAccountId( + accountId: string, + anyAccountId?: string +): Promise { + const kvAccountId = anyAccountId ?? accountId + + // 1. Save to local cache immediately (fast) + const local = readLocalPreferences() + writeLocalPreferences({ + ...local, + defaultAccountId: accountId, + lastUpdated: new Date().toISOString() + }) + + // 2. Save to cloud KV (for sync across machines) + try { + const namespaceId = await getOrCreatePreferencesNamespace(kvAccountId) + await kvPut(kvAccountId, namespaceId, GLOBAL_ACCOUNT_KEY, accountId) + } catch (error) { + console.debug('[devflare preferences] cloud KV sync failed:', error instanceof Error ? error.message : String(error)) + } +} + +/** + * Get the effective account ID to use + * + * Priority: + * 1. Workspace (package.json) - highest priority + * 2. Global default (local cache + cloud KV) + * 3. Primary account (first account in list) + * + * @param primaryAccountId - The primary account ID to use as fallback + */ +export async function getEffectiveAccountId( + primaryAccountId: string +): Promise<{ accountId: string; source: 'workspace' | 'global' | 'primary' }> { + // 1. Check workspace first + const workspaceId = getWorkspaceAccountId() + if (workspaceId) { + return { accountId: workspaceId, source: 'workspace' } + } + + // 2. Check global default + const globalId = await getGlobalDefaultAccountId(primaryAccountId) + if (globalId) { + return { accountId: globalId, source: 'global' } + } + + // 3. Use primary account + return { accountId: primaryAccountId, source: 'primary' } +} + +/** + * Clear the global default account ID (both local and cloud) + */ +export async function clearGlobalDefaultAccountId( + anyAccountId: string +): Promise { + // Clear local cache + const local = readLocalPreferences() + delete local.defaultAccountId + local.lastUpdated = new Date().toISOString() + writeLocalPreferences(local) + + // Clear from cloud KV + try { + const namespaceId = await getOrCreatePreferencesNamespace(anyAccountId) + await kvDelete(anyAccountId, namespaceId, GLOBAL_ACCOUNT_KEY) + } catch (error) { + console.debug('[devflare preferences] cloud KV sync failed:', error instanceof Error ? error.message : String(error)) + } +} diff --git a/packages/devflare/src/cloudflare/preview-registry-cache.ts b/packages/devflare/src/cloudflare/preview-registry-cache.ts new file mode 100644 index 0000000..1bc88cc --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-cache.ts @@ -0,0 +1,101 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + DEVFLARE_PREVIEW_REGISTRY_DATABASE, + type PreviewRegistryCacheFile, + type PreviewRegistryContext +} from './preview-registry-types' + +const DEVFLARE_CACHE_DIR = '.devflare' +const PREVIEW_REGISTRY_CACHE_FILE = 'preview-registry.json' + +function getDevflareCacheDir(): string { + const override = process.env.DEVFLARE_CACHE_DIR?.trim() + if (override) { + return override + } + + return join(homedir(), DEVFLARE_CACHE_DIR) +} + +function getPreviewRegistryCachePath(): string { + return join(getDevflareCacheDir(), PREVIEW_REGISTRY_CACHE_FILE) +} + +function getPreviewRegistryCacheKey(accountId: string, databaseName: string): string { + return `${accountId}:${databaseName}` +} + +function readPreviewRegistryCache(): PreviewRegistryCacheFile { + const cachePath = getPreviewRegistryCachePath() + if (!existsSync(cachePath)) { + return {} + } + + try { + const content = readFileSync(cachePath, 'utf-8') + return JSON.parse(content) as PreviewRegistryCacheFile + } catch { + return {} + } +} + +function writePreviewRegistryCache(cache: PreviewRegistryCacheFile): void { + try { + const cacheDir = getDevflareCacheDir() + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }) + } + + writeFileSync(getPreviewRegistryCachePath(), JSON.stringify(cache, null, '\t'), 'utf-8') + } catch { + // Best-effort local cache only. + } +} + +export function getRegistryDatabaseName(databaseName?: string): string { + return databaseName?.trim() || DEVFLARE_PREVIEW_REGISTRY_DATABASE +} + +export function getCachedPreviewRegistryContext( + accountId: string, + databaseName: string +): PreviewRegistryContext | null { + const entry = readPreviewRegistryCache().registries?.[getPreviewRegistryCacheKey(accountId, databaseName)] + if (!entry?.databaseId) { + return null + } + + return { + accountId: entry.accountId, + databaseId: entry.databaseId, + databaseName: entry.databaseName, + created: false + } +} + +export function cachePreviewRegistryContext(registry: PreviewRegistryContext): void { + const cache = readPreviewRegistryCache() + const registries = cache.registries ?? {} + registries[getPreviewRegistryCacheKey(registry.accountId, registry.databaseName)] = { + accountId: registry.accountId, + databaseId: registry.databaseId, + databaseName: registry.databaseName, + updatedAt: new Date().toISOString() + } + writePreviewRegistryCache({ + ...cache, + registries + }) +} + +export function clearCachedPreviewRegistryContext(accountId: string, databaseName: string): void { + const cache = readPreviewRegistryCache() + if (!cache.registries) { + return + } + + delete cache.registries[getPreviewRegistryCacheKey(accountId, databaseName)] + writePreviewRegistryCache(cache) +} diff --git a/packages/devflare/src/cloudflare/preview-registry-inference.ts b/packages/devflare/src/cloudflare/preview-registry-inference.ts new file mode 100644 index 0000000..86a08af --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-inference.ts @@ -0,0 +1,138 @@ +import type { + DevflareDeploymentRecord, + DevflarePreviewScopeRecord, + DevflarePreviewRecord, + DevflareRecordSource +} from './registry-schema' +import type { + ReconcilePreviewRegistryOptions, + RetirePreviewRegistryOptions +} from './preview-registry-types' + +export function toIsoString(date: Date | undefined): string | null { + return date ? date.toISOString() : null +} + +export function inferRecordSource( + explicitSource: DevflareRecordSource | undefined, + fallbackSource: string | undefined +): DevflareRecordSource { + if (explicitSource) { + return explicitSource + } + + if (fallbackSource === 'dashboard') { + return 'dashboard' + } + + if (fallbackSource === 'workers-builds') { + return 'workers-builds' + } + + if (fallbackSource === 'wrangler') { + return process.env.GITHUB_ACTIONS === 'true' ? 'github-action' : 'cli' + } + + return 'unknown' +} + +export function getPreviewRecordId(workerName: string, versionId: string): string { + return `preview:${workerName}:${versionId}` +} + +export function getPreviewScopeRecordId(workerName: string, scope: string): string { + return `previewScope:${workerName}:${scope}` +} + +export function getPreviewDeploymentId(workerName: string, versionId: string): string { + return `preview:${workerName}:${versionId}` +} + +export function getDeploymentRecordId(workerName: string, deploymentId: string): string { + return `deployment:${workerName}:${deploymentId}` +} + +export function hasRetireSelector(options: RetirePreviewRegistryOptions): boolean { + return Boolean( + options.branchName + || options.previewScope + || options.versionId + || options.commitSha + ) +} + +function matchesRetireSelector( + options: RetirePreviewRegistryOptions, + candidate: { + branchName?: string | null + previewScope?: string | null + versionId?: string | null + commitSha?: string | null + } +): boolean { + return (options.branchName !== undefined && candidate.branchName === options.branchName) + || (options.previewScope !== undefined && candidate.previewScope === options.previewScope) + || (options.versionId !== undefined && candidate.versionId === options.versionId) + || (options.commitSha !== undefined && candidate.commitSha === options.commitSha) +} + +function getPreviewRetireCandidate(record: { + branchName?: string | null + scope?: string | null + versionId?: string | null + commitSha?: string | null +}): { + branchName?: string | null + previewScope?: string | null + versionId?: string | null + commitSha?: string | null +} { + return { + branchName: record.branchName, + previewScope: record.scope, + versionId: record.versionId, + commitSha: record.commitSha + } +} + +export function matchesPreviewRetireTarget( + record: DevflarePreviewRecord, + options: RetirePreviewRegistryOptions +): boolean { + return matchesRetireSelector(options, getPreviewRetireCandidate(record)) +} + +export function matchesPreviewScopeRetireTarget( + record: DevflarePreviewScopeRecord, + options: RetirePreviewRegistryOptions +): boolean { + return matchesRetireSelector(options, getPreviewRetireCandidate(record)) +} + +export function matchesPreviewDeploymentRetireTarget( + record: DevflareDeploymentRecord, + options: RetirePreviewRegistryOptions +): boolean { + return record.channel === 'preview' + && matchesRetireSelector(options, { + versionId: record.versionId, + commitSha: record.commitSha + }) +} + +export function getExplicitPreviewSyncOverrides( + options: ReconcilePreviewRegistryOptions, + versionId: string +): Pick { + if (versionId !== options.versionId) { + return {} + } + + return { + previewScope: options.previewScope, + previewUrl: options.previewUrl, + previewScopeUrl: options.previewScopeUrl, + branchName: options.branchName, + commitSha: options.commitSha + } +} diff --git a/packages/devflare/src/cloudflare/preview-registry-records.ts b/packages/devflare/src/cloudflare/preview-registry-records.ts new file mode 100644 index 0000000..20ad02d --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-records.ts @@ -0,0 +1,7 @@ +// Barrel module: preview-registry-records splits into three cohesive layers. +// - Transport: Cloudflare API reads โ†’ ./preview-registry-transport +// - Inference: pure normalized-model derivation โ†’ ./preview-registry-inference +// - Shape: persistence-oriented record projection โ†’ ./preview-registry-shape +export * from './preview-registry-inference' +export * from './preview-registry-shape' +export * from './preview-registry-transport' diff --git a/packages/devflare/src/cloudflare/preview-registry-shape.ts b/packages/devflare/src/cloudflare/preview-registry-shape.ts new file mode 100644 index 0000000..6b5fadb --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-shape.ts @@ -0,0 +1,245 @@ +import type { + WorkerDeploymentInfo, + WorkerVersionInfo +} from './types' +import { + devflareDeploymentRecordSchema, + devflarePreviewScopeRecordSchema, + devflarePreviewRecordSchema, + type DevflareDeploymentRecord, + type DevflarePreviewScopeRecord, + type DevflarePreviewRecord, + type DevflareRecordSource +} from './registry-schema' +import { + getDeploymentRecordId, + getPreviewDeploymentId, + getPreviewRecordId, + getPreviewScopeRecordId, + inferRecordSource +} from './preview-registry-inference' +import { formatVersionPreviewUrl } from './preview-urls' + +interface RegistryRecordParser { + parse(value: unknown): TRecord +} + +function markRecordDeleted( + record: TRecord, + now: Date, + parser: RegistryRecordParser +): TRecord { + return parser.parse({ + ...record, + updatedAt: now, + deletedAt: now, + status: 'deleted' + }) +} + +function getVersionAuthorId( + version: WorkerVersionInfo | undefined, + existingCreatedBy: string | undefined +): string { + return version?.metadata.authorId || existingCreatedBy || 'unknown' +} + +function createPreviewLinkedRecordBase(options: { + accountId: string + workerName: string + previewRecord: DevflarePreviewRecord + existingCreatedAt?: Date + now: Date +}): { + createdAt: Date + updatedAt: Date + deletedAt: undefined + createdBy: string + accountId: string + workerName: string + versionId: string + previewId: string + commitSha?: string + source: DevflareRecordSource + status: 'active' +} { + return { + createdAt: options.existingCreatedAt ?? options.previewRecord.createdAt, + updatedAt: options.now, + deletedAt: undefined, + createdBy: options.previewRecord.createdBy, + accountId: options.accountId, + workerName: options.workerName, + versionId: options.previewRecord.versionId, + previewId: options.previewRecord.id, + commitSha: options.previewRecord.commitSha, + source: options.previewRecord.source, + status: 'active' + } +} + +export function buildPreviewRecord(options: { + accountId: string + workerName: string + version: WorkerVersionInfo + existing?: DevflarePreviewRecord + workersSubdomain?: string | null + previewScope?: string + previewUrl?: string + previewScopeUrl?: string + branchName?: string + commitSha?: string + source?: DevflareRecordSource + now: Date +}): DevflarePreviewRecord | null { + const scope = options.previewScope ?? options.existing?.scope + const previewUrl = options.previewUrl + ?? options.existing?.previewUrl + ?? (options.workersSubdomain + ? formatVersionPreviewUrl(options.version.id, options.workerName, options.workersSubdomain) + : undefined) + const scopeChanged = options.previewScope !== undefined && options.previewScope !== options.existing?.scope + const scopeUrl = options.previewScopeUrl + ?? (options.previewScope !== undefined ? options.previewUrl : undefined) + ?? (!scopeChanged ? options.existing?.scopeUrl : undefined) + + if (!previewUrl) { + return null + } + + return devflarePreviewRecordSchema.parse({ + id: getPreviewRecordId(options.workerName, options.version.id), + kind: 'preview', + ver: 1, + createdAt: options.existing?.createdAt ?? options.version.metadata.createdOn ?? options.now, + updatedAt: options.now, + deletedAt: undefined, + createdBy: getVersionAuthorId(options.version, options.existing?.createdBy), + accountId: options.accountId, + workerName: options.workerName, + versionId: options.version.id, + previewUrl, + scope, + scopeUrl, + branchName: options.branchName ?? options.existing?.branchName, + commitSha: options.commitSha ?? options.existing?.commitSha, + deploymentId: options.existing?.deploymentId, + source: inferRecordSource(options.source, options.version.metadata.source), + status: 'active' + }) +} + +export function buildPreviewScopeRecord(options: { + accountId: string + workerName: string + previewRecord: DevflarePreviewRecord + existing?: DevflarePreviewScopeRecord + now: Date +}): DevflarePreviewScopeRecord | null { + if (!options.previewRecord.scope || !options.previewRecord.scopeUrl) { + return null + } + + return devflarePreviewScopeRecordSchema.parse({ + id: getPreviewScopeRecordId(options.workerName, options.previewRecord.scope), + kind: 'previewScope', + ver: 1, + ...createPreviewLinkedRecordBase({ + accountId: options.accountId, + workerName: options.workerName, + previewRecord: options.previewRecord, + existingCreatedAt: options.existing?.createdAt, + now: options.now + }), + scope: options.previewRecord.scope, + scopeUrl: options.previewRecord.scopeUrl, + branchName: options.previewRecord.branchName, + }) +} + +export function buildPreviewDeploymentRecord(options: { + accountId: string + workerName: string + previewRecord: DevflarePreviewRecord + existing?: DevflareDeploymentRecord + now: Date +}): DevflareDeploymentRecord { + const deploymentId = getPreviewDeploymentId(options.workerName, options.previewRecord.versionId) + return devflareDeploymentRecordSchema.parse({ + id: getDeploymentRecordId(options.workerName, deploymentId), + kind: 'deployment', + ver: 1, + ...createPreviewLinkedRecordBase({ + accountId: options.accountId, + workerName: options.workerName, + previewRecord: options.previewRecord, + existingCreatedAt: options.existing?.createdAt, + now: options.now + }), + deploymentId, + channel: 'preview', + environment: 'preview', + url: options.previewRecord.scopeUrl ?? options.previewRecord.previewUrl, + message: options.existing?.message, + }) +} + +export function buildProductionDeploymentRecord(options: { + accountId: string + workerName: string + deployment: WorkerDeploymentInfo + version: WorkerVersionInfo | undefined + existing?: DevflareDeploymentRecord + workersSubdomain?: string | null + source?: DevflareRecordSource + commitSha?: string + deploymentMessage?: string + status: 'active' | 'superseded' + now: Date +}): DevflareDeploymentRecord | null { + const versionId = options.deployment.versions[0]?.versionId + if (!versionId) { + return null + } + + const productionUrl = options.workersSubdomain + ? `https://${options.workerName}.${options.workersSubdomain}.workers.dev` + : options.existing?.url + + return devflareDeploymentRecordSchema.parse({ + id: getDeploymentRecordId(options.workerName, options.deployment.id), + kind: 'deployment', + ver: 1, + createdAt: options.existing?.createdAt ?? options.deployment.createdOn, + updatedAt: options.now, + deletedAt: undefined, + createdBy: getVersionAuthorId(options.version, options.existing?.createdBy), + accountId: options.accountId, + workerName: options.workerName, + deploymentId: options.deployment.id, + channel: 'production', + status: options.status, + versionId, + environment: 'production', + url: productionUrl, + message: options.deploymentMessage ?? options.deployment.message ?? options.existing?.message, + commitSha: options.commitSha ?? options.existing?.commitSha, + source: inferRecordSource(options.source, options.version?.metadata.source ?? options.deployment.source) + }) +} + +export function markPreviewRecordDeleted(record: DevflarePreviewRecord, now: Date): DevflarePreviewRecord { + return markRecordDeleted(record, now, devflarePreviewRecordSchema) +} + +export function markPreviewScopeRecordDeleted(record: DevflarePreviewScopeRecord, now: Date): DevflarePreviewScopeRecord { + return markRecordDeleted(record, now, devflarePreviewScopeRecordSchema) +} + +export function markDeploymentRecordDeleted(record: DevflareDeploymentRecord, now: Date): DevflareDeploymentRecord { + return markRecordDeleted(record, now, devflareDeploymentRecordSchema) +} diff --git a/packages/devflare/src/cloudflare/preview-registry-store.ts b/packages/devflare/src/cloudflare/preview-registry-store.ts new file mode 100644 index 0000000..1947e26 --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-store.ts @@ -0,0 +1,591 @@ +import { queryD1Database } from './account-resources' +import { type APIClientOptions, CloudflareAPIError } from './api' +import { toIsoString } from './preview-registry-records' +import type { PreviewRegistryContext, StoredRecordRow } from './preview-registry-types' +import { + type DevflareDeploymentRecord, + type DevflarePreviewRecord, + type DevflarePreviewScopeRecord, + devflareDeploymentRecordSchema, + devflarePreviewRecordSchema, + devflarePreviewScopeRecordSchema +} from './registry-schema' + +interface RegistryTableSchema { + name: string + createStatement: string + migrationColumns: Record + indexStatements: string[] +} + +const REGISTRY_TABLES: RegistryTableSchema[] = [ + { + name: 'devflare_preview_records', + createStatement: `CREATE TABLE IF NOT EXISTS devflare_preview_records ( + id TEXT PRIMARY KEY, + ver INTEGER NOT NULL, + account_id TEXT NOT NULL, + worker_name TEXT NOT NULL, + version_id TEXT NOT NULL UNIQUE, + preview_url TEXT NOT NULL, + scope TEXT, + scope_url TEXT, + branch_name TEXT, + commit_sha TEXT, + deployment_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted_at TEXT, + payload_json TEXT NOT NULL + )`, + migrationColumns: { + id: 'id TEXT', + ver: 'ver INTEGER NOT NULL DEFAULT 1', + account_id: `account_id TEXT NOT NULL DEFAULT ''`, + worker_name: `worker_name TEXT NOT NULL DEFAULT ''`, + version_id: `version_id TEXT NOT NULL DEFAULT ''`, + preview_url: `preview_url TEXT NOT NULL DEFAULT ''`, + scope: 'scope TEXT', + scope_url: 'scope_url TEXT', + branch_name: 'branch_name TEXT', + commit_sha: 'commit_sha TEXT', + deployment_id: 'deployment_id TEXT', + source: `source TEXT NOT NULL DEFAULT ''`, + status: `status TEXT NOT NULL DEFAULT ''`, + created_by: `created_by TEXT NOT NULL DEFAULT ''`, + created_at: `created_at TEXT NOT NULL DEFAULT ''`, + updated_at: 'updated_at TEXT', + deleted_at: 'deleted_at TEXT', + payload_json: `payload_json TEXT NOT NULL DEFAULT '{}'` + }, + indexStatements: [ + 'CREATE INDEX IF NOT EXISTS idx_devflare_preview_records_account_worker ON devflare_preview_records(account_id, worker_name)', + 'CREATE INDEX IF NOT EXISTS idx_devflare_preview_records_status ON devflare_preview_records(status)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_devflare_preview_records_version_id ON devflare_preview_records(version_id)' + ] + }, + { + name: 'devflare_preview_scope_records', + createStatement: `CREATE TABLE IF NOT EXISTS devflare_preview_scope_records ( + id TEXT PRIMARY KEY, + ver INTEGER NOT NULL, + account_id TEXT NOT NULL, + worker_name TEXT NOT NULL, + scope TEXT NOT NULL, + scope_url TEXT NOT NULL, + version_id TEXT NOT NULL, + preview_id TEXT, + branch_name TEXT, + commit_sha TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted_at TEXT, + payload_json TEXT NOT NULL + )`, + migrationColumns: { + id: 'id TEXT', + ver: 'ver INTEGER NOT NULL DEFAULT 1', + account_id: `account_id TEXT NOT NULL DEFAULT ''`, + worker_name: `worker_name TEXT NOT NULL DEFAULT ''`, + scope: `scope TEXT NOT NULL DEFAULT ''`, + scope_url: `scope_url TEXT NOT NULL DEFAULT ''`, + version_id: `version_id TEXT NOT NULL DEFAULT ''`, + preview_id: 'preview_id TEXT', + branch_name: 'branch_name TEXT', + commit_sha: 'commit_sha TEXT', + source: `source TEXT NOT NULL DEFAULT ''`, + status: `status TEXT NOT NULL DEFAULT ''`, + created_by: `created_by TEXT NOT NULL DEFAULT ''`, + created_at: `created_at TEXT NOT NULL DEFAULT ''`, + updated_at: 'updated_at TEXT', + deleted_at: 'deleted_at TEXT', + payload_json: `payload_json TEXT NOT NULL DEFAULT '{}'` + }, + indexStatements: [ + 'CREATE INDEX IF NOT EXISTS idx_devflare_preview_scope_records_account_worker ON devflare_preview_scope_records(account_id, worker_name)', + 'CREATE INDEX IF NOT EXISTS idx_devflare_preview_scope_records_scope ON devflare_preview_scope_records(scope)' + ] + }, + { + name: 'devflare_deployment_records', + createStatement: `CREATE TABLE IF NOT EXISTS devflare_deployment_records ( + id TEXT PRIMARY KEY, + ver INTEGER NOT NULL, + account_id TEXT NOT NULL, + worker_name TEXT NOT NULL, + deployment_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + status TEXT NOT NULL, + version_id TEXT NOT NULL, + preview_id TEXT, + environment TEXT, + url TEXT, + message TEXT, + commit_sha TEXT, + source TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted_at TEXT, + payload_json TEXT NOT NULL + )`, + migrationColumns: { + id: 'id TEXT', + ver: 'ver INTEGER NOT NULL DEFAULT 1', + account_id: `account_id TEXT NOT NULL DEFAULT ''`, + worker_name: `worker_name TEXT NOT NULL DEFAULT ''`, + deployment_id: `deployment_id TEXT NOT NULL DEFAULT ''`, + channel: `channel TEXT NOT NULL DEFAULT ''`, + status: `status TEXT NOT NULL DEFAULT ''`, + version_id: `version_id TEXT NOT NULL DEFAULT ''`, + preview_id: 'preview_id TEXT', + environment: 'environment TEXT', + url: 'url TEXT', + message: 'message TEXT', + commit_sha: 'commit_sha TEXT', + source: `source TEXT NOT NULL DEFAULT ''`, + created_by: `created_by TEXT NOT NULL DEFAULT ''`, + created_at: `created_at TEXT NOT NULL DEFAULT ''`, + updated_at: 'updated_at TEXT', + deleted_at: 'deleted_at TEXT', + payload_json: `payload_json TEXT NOT NULL DEFAULT '{}'` + }, + indexStatements: [ + 'CREATE INDEX IF NOT EXISTS idx_devflare_deployment_records_account_worker ON devflare_deployment_records(account_id, worker_name)', + 'CREATE INDEX IF NOT EXISTS idx_devflare_deployment_records_channel_status ON devflare_deployment_records(channel, status)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_devflare_deployment_records_deployment_id ON devflare_deployment_records(deployment_id)' + ] + } +] + +const schemaEnsuredRegistryIds = new Set() + +async function runQuery>( + registry: PreviewRegistryContext, + sql: string, + params: Array = [], + apiOptions?: APIClientOptions +): Promise { + const results = await queryD1Database( + registry.accountId, + registry.databaseId, + { + sql, + params + }, + apiOptions + ) + + return results[0]?.results ?? [] +} + +async function runStatement( + registry: PreviewRegistryContext, + sql: string, + params: Array = [], + apiOptions?: APIClientOptions +): Promise { + await queryD1Database( + registry.accountId, + registry.databaseId, + { + sql, + params + }, + apiOptions + ) +} + +function quoteSqlIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"` +} + +async function readTableColumnNames( + registry: PreviewRegistryContext, + tableName: string, + apiOptions?: APIClientOptions +): Promise> { + const rows = await runQuery<{ name?: unknown }>( + registry, + `PRAGMA table_info(${quoteSqlIdentifier(tableName)})`, + [], + apiOptions + ) + + return new Set( + rows + .map((row) => (typeof row.name === 'string' ? row.name : undefined)) + .filter((value): value is string => Boolean(value)) + ) +} + +async function ensureTableColumns( + registry: PreviewRegistryContext, + table: RegistryTableSchema, + apiOptions?: APIClientOptions +): Promise { + const existingColumnNames = await readTableColumnNames(registry, table.name, apiOptions) + + for (const [columnName, definition] of Object.entries(table.migrationColumns)) { + if (existingColumnNames.has(columnName)) { + continue + } + + await runStatement( + registry, + `ALTER TABLE ${quoteSqlIdentifier(table.name)} ADD COLUMN ${definition}`, + [], + apiOptions + ) + } +} + +export async function ensurePreviewRegistrySchema( + registry: PreviewRegistryContext, + apiOptions?: APIClientOptions +): Promise { + if (schemaEnsuredRegistryIds.has(registry.databaseId)) { + return + } + + for (const table of REGISTRY_TABLES) { + await runStatement(registry, table.createStatement, [], apiOptions) + await ensureTableColumns(registry, table, apiOptions) + + for (const statement of table.indexStatements) { + await runStatement(registry, statement, [], apiOptions) + } + } + + schemaEnsuredRegistryIds.add(registry.databaseId) +} + +export function clearPreviewRegistrySchemaCache(databaseId: string): void { + schemaEnsuredRegistryIds.delete(databaseId) +} + +export function isMissingRegistrySchemaError(error: unknown): boolean { + if (error instanceof CloudflareAPIError) { + const message = error.message.toLowerCase() + return message.includes('no such table') || message.includes('no such column') + } + + if (error instanceof Error) { + const message = error.message.toLowerCase() + return message.includes('no such table') || message.includes('no such column') + } + + return false +} + +export function isUnavailableRegistryContextError(error: unknown): boolean { + if (error instanceof CloudflareAPIError) { + const message = error.message.toLowerCase() + return ( + error.code === 404 || + ((message.includes('database') || message.includes('d1')) && + (message.includes('not found') || + message.includes('does not exist') || + message.includes('unknown'))) + ) + } + + if (error instanceof Error) { + const message = error.message.toLowerCase() + return ( + (message.includes('database') || message.includes('d1')) && + (message.includes('not found') || message.includes('does not exist')) + ) + } + + return false +} + +function normalizeLegacyStoredRecordPayload(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value + } + + const { alias, aliasPreviewUrl, ...record } = value as Record + + if (record.scope === undefined && typeof alias === 'string') { + record.scope = alias + } + + if (record.scopeUrl === undefined && typeof aliasPreviewUrl === 'string') { + record.scopeUrl = aliasPreviewUrl + } + + return record +} + +function parseStoredRecordPayload(row: StoredRecordRow): unknown { + return normalizeLegacyStoredRecordPayload(JSON.parse(row.payload_json)) +} + +function parseStoredPreviewRecord(row: StoredRecordRow): DevflarePreviewRecord { + return devflarePreviewRecordSchema.parse(parseStoredRecordPayload(row)) +} + +function parseStoredPreviewScopeRecord(row: StoredRecordRow): DevflarePreviewScopeRecord { + return devflarePreviewScopeRecordSchema.parse(parseStoredRecordPayload(row)) +} + +function parseStoredDeploymentRecord(row: StoredRecordRow): DevflareDeploymentRecord { + return devflareDeploymentRecordSchema.parse(parseStoredRecordPayload(row)) +} + +export async function readPreviewRows( + registry: PreviewRegistryContext, + workerName: string | undefined, + apiOptions?: APIClientOptions +): Promise { + const sql = workerName + ? 'SELECT payload_json FROM devflare_preview_records WHERE account_id = ? AND worker_name = ? ORDER BY created_at DESC' + : 'SELECT payload_json FROM devflare_preview_records WHERE account_id = ? ORDER BY created_at DESC' + const params = workerName ? [registry.accountId, workerName] : [registry.accountId] + const rows = await runQuery(registry, sql, params, apiOptions) + return rows.map((row) => parseStoredPreviewRecord(row)) +} + +export async function readPreviewScopeRows( + registry: PreviewRegistryContext, + workerName: string | undefined, + apiOptions?: APIClientOptions +): Promise { + const sql = workerName + ? 'SELECT payload_json FROM devflare_preview_scope_records WHERE account_id = ? AND worker_name = ? ORDER BY created_at DESC' + : 'SELECT payload_json FROM devflare_preview_scope_records WHERE account_id = ? ORDER BY created_at DESC' + const params = workerName ? [registry.accountId, workerName] : [registry.accountId] + const rows = await runQuery(registry, sql, params, apiOptions) + return rows.map((row) => parseStoredPreviewScopeRecord(row)) +} + +export async function readDeploymentRows( + registry: PreviewRegistryContext, + workerName: string | undefined, + apiOptions?: APIClientOptions +): Promise { + const sql = workerName + ? 'SELECT payload_json FROM devflare_deployment_records WHERE account_id = ? AND worker_name = ? ORDER BY created_at DESC' + : 'SELECT payload_json FROM devflare_deployment_records WHERE account_id = ? ORDER BY created_at DESC' + const params = workerName ? [registry.accountId, workerName] : [registry.accountId] + const rows = await runQuery(registry, sql, params, apiOptions) + return rows.map((row) => parseStoredDeploymentRecord(row)) +} + +export async function upsertPreviewRecord( + registry: PreviewRegistryContext, + record: DevflarePreviewRecord, + apiOptions?: APIClientOptions +): Promise { + const normalizedRecord = devflarePreviewRecordSchema.parse(record) + await runStatement( + registry, + `INSERT INTO devflare_preview_records ( + id, + ver, + account_id, + worker_name, + version_id, + preview_url, + scope, + scope_url, + branch_name, + commit_sha, + deployment_id, + source, + status, + created_by, + created_at, + updated_at, + deleted_at, + payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ver = excluded.ver, + account_id = excluded.account_id, + worker_name = excluded.worker_name, + version_id = excluded.version_id, + preview_url = excluded.preview_url, + scope = excluded.scope, + scope_url = excluded.scope_url, + branch_name = excluded.branch_name, + commit_sha = excluded.commit_sha, + deployment_id = excluded.deployment_id, + source = excluded.source, + status = excluded.status, + created_by = excluded.created_by, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at, + payload_json = excluded.payload_json`, + [ + normalizedRecord.id, + normalizedRecord.ver, + normalizedRecord.accountId, + normalizedRecord.workerName, + normalizedRecord.versionId, + normalizedRecord.previewUrl, + normalizedRecord.scope ?? null, + normalizedRecord.scopeUrl ?? null, + normalizedRecord.branchName ?? null, + normalizedRecord.commitSha ?? null, + normalizedRecord.deploymentId ?? null, + normalizedRecord.source, + normalizedRecord.status, + normalizedRecord.createdBy, + normalizedRecord.createdAt.toISOString(), + toIsoString(normalizedRecord.updatedAt), + toIsoString(normalizedRecord.deletedAt), + JSON.stringify(normalizedRecord) + ], + apiOptions + ) +} + +export async function upsertPreviewScopeRecord( + registry: PreviewRegistryContext, + record: DevflarePreviewScopeRecord, + apiOptions?: APIClientOptions +): Promise { + const normalizedRecord = devflarePreviewScopeRecordSchema.parse(record) + await runStatement( + registry, + `INSERT INTO devflare_preview_scope_records ( + id, + ver, + account_id, + worker_name, + scope, + scope_url, + version_id, + preview_id, + branch_name, + commit_sha, + source, + status, + created_by, + created_at, + updated_at, + deleted_at, + payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ver = excluded.ver, + account_id = excluded.account_id, + worker_name = excluded.worker_name, + scope = excluded.scope, + scope_url = excluded.scope_url, + version_id = excluded.version_id, + preview_id = excluded.preview_id, + branch_name = excluded.branch_name, + commit_sha = excluded.commit_sha, + source = excluded.source, + status = excluded.status, + created_by = excluded.created_by, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at, + payload_json = excluded.payload_json`, + [ + normalizedRecord.id, + normalizedRecord.ver, + normalizedRecord.accountId, + normalizedRecord.workerName, + normalizedRecord.scope, + normalizedRecord.scopeUrl, + normalizedRecord.versionId, + normalizedRecord.previewId ?? null, + normalizedRecord.branchName ?? null, + normalizedRecord.commitSha ?? null, + normalizedRecord.source, + normalizedRecord.status, + normalizedRecord.createdBy, + normalizedRecord.createdAt.toISOString(), + toIsoString(normalizedRecord.updatedAt), + toIsoString(normalizedRecord.deletedAt), + JSON.stringify(normalizedRecord) + ], + apiOptions + ) +} + +export async function upsertDeploymentRecord( + registry: PreviewRegistryContext, + record: DevflareDeploymentRecord, + apiOptions?: APIClientOptions +): Promise { + const normalizedRecord = devflareDeploymentRecordSchema.parse(record) + await runStatement( + registry, + `INSERT INTO devflare_deployment_records ( + id, + ver, + account_id, + worker_name, + deployment_id, + channel, + status, + version_id, + preview_id, + environment, + url, + message, + commit_sha, + source, + created_by, + created_at, + updated_at, + deleted_at, + payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ver = excluded.ver, + account_id = excluded.account_id, + worker_name = excluded.worker_name, + deployment_id = excluded.deployment_id, + channel = excluded.channel, + status = excluded.status, + version_id = excluded.version_id, + preview_id = excluded.preview_id, + environment = excluded.environment, + url = excluded.url, + message = excluded.message, + commit_sha = excluded.commit_sha, + source = excluded.source, + created_by = excluded.created_by, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at, + payload_json = excluded.payload_json`, + [ + normalizedRecord.id, + normalizedRecord.ver, + normalizedRecord.accountId, + normalizedRecord.workerName, + normalizedRecord.deploymentId, + normalizedRecord.channel, + normalizedRecord.status, + normalizedRecord.versionId, + normalizedRecord.previewId ?? null, + normalizedRecord.environment ?? null, + normalizedRecord.url ?? null, + normalizedRecord.message ?? null, + normalizedRecord.commitSha ?? null, + normalizedRecord.source, + normalizedRecord.createdBy, + normalizedRecord.createdAt.toISOString(), + toIsoString(normalizedRecord.updatedAt), + toIsoString(normalizedRecord.deletedAt), + JSON.stringify(normalizedRecord) + ], + apiOptions + ) +} diff --git a/packages/devflare/src/cloudflare/preview-registry-transport.ts b/packages/devflare/src/cloudflare/preview-registry-transport.ts new file mode 100644 index 0000000..fbe0cbe --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-transport.ts @@ -0,0 +1,24 @@ +import { getWorkerVersionDetail } from './account-workers' +import type { APIClientOptions } from './api' +import type { WorkerVersionInfo } from './types' + +export async function getVersionInfoById( + accountId: string, + workerName: string, + versionId: string, + versionMap: Map, + apiOptions?: APIClientOptions +): Promise { + const existing = versionMap.get(versionId) + if (existing) { + return existing + } + + try { + const version = await getWorkerVersionDetail(accountId, workerName, versionId, apiOptions) + versionMap.set(versionId, version) + return version + } catch { + return undefined + } +} diff --git a/packages/devflare/src/cloudflare/preview-registry-types.ts b/packages/devflare/src/cloudflare/preview-registry-types.ts new file mode 100644 index 0000000..78e23c7 --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry-types.ts @@ -0,0 +1,120 @@ +import type { ConsolaInstance } from 'consola' +import type { APIClientOptions } from './api' +import type { + DevflareDeploymentRecord, + DevflarePreviewScopeRecord, + DevflarePreviewRecord, + DevflareRecordSource +} from './registry-schema' + +export const DEVFLARE_PREVIEW_REGISTRY_DATABASE = 'devflare-registry' + +export interface StoredRecordRow { + payload_json: string +} + +export interface PreviewRegistryCacheEntry { + accountId: string + databaseId: string + databaseName: string + updatedAt: string +} + +export interface PreviewRegistryCacheFile { + registries?: Record +} + +export interface PreviewRegistryContext { + accountId: string + databaseId: string + databaseName: string + created: boolean +} + +export interface ListTrackedRecordsOptions { + accountId: string + workerName?: string + databaseName?: string + apiOptions?: APIClientOptions +} + +export interface ListTrackedRegistryStateOptions { + registry: PreviewRegistryContext + workerName?: string + apiOptions?: APIClientOptions +} + +export interface ReconcilePreviewRegistryOptions { + accountId: string + workerName: string + databaseName?: string + apiOptions?: APIClientOptions + previewScope?: string + previewUrl?: string + previewScopeUrl?: string + branchName?: string + commitSha?: string + versionId?: string + source?: DevflareRecordSource + deploymentMessage?: string + logger?: ConsolaInstance + now?: Date +} + +export interface ReconcilePreviewRegistryResult { + registry: PreviewRegistryContext + previews: DevflarePreviewRecord[] + previewScopes: DevflarePreviewScopeRecord[] + deployments: DevflareDeploymentRecord[] +} + +export interface CleanupPreviewRegistryOptions { + accountId: string + workerName?: string + databaseName?: string + apiOptions?: APIClientOptions + days?: number + apply?: boolean + logger?: ConsolaInstance + now?: Date +} + +export interface CleanupPreviewRegistryResult { + registry: PreviewRegistryContext + previews: DevflarePreviewRecord[] + scopes: DevflarePreviewScopeRecord[] + deployments: DevflareDeploymentRecord[] + candidates: { + previews: DevflarePreviewRecord[] + scopes: DevflarePreviewScopeRecord[] + deployments: DevflareDeploymentRecord[] + } + applied: boolean +} + +export interface RetirePreviewRegistryOptions { + accountId: string + workerName: string + databaseName?: string + apiOptions?: APIClientOptions + branchName?: string + previewScope?: string + versionId?: string + commitSha?: string + apply?: boolean + logger?: ConsolaInstance + now?: Date +} + +export interface RetirePreviewRegistryResult { + registry: PreviewRegistryContext + previews: DevflarePreviewRecord[] + scopes: DevflarePreviewScopeRecord[] + deployments: DevflareDeploymentRecord[] + candidates: { + previews: DevflarePreviewRecord[] + scopes: DevflarePreviewScopeRecord[] + deployments: DevflareDeploymentRecord[] + } + applied: boolean +} diff --git a/packages/devflare/src/cloudflare/preview-registry.ts b/packages/devflare/src/cloudflare/preview-registry.ts new file mode 100644 index 0000000..89eb14a --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-registry.ts @@ -0,0 +1,556 @@ +import { createD1Database, getWorkersSubdomain, listD1Databases, listWorkerDeployments, listWorkerVersions } from './account' +import type { APIClientOptions } from './api' +import { cachePreviewRegistryContext, clearCachedPreviewRegistryContext, getCachedPreviewRegistryContext, getRegistryDatabaseName } from './preview-registry-cache' +import { + buildPreviewScopeRecord, + buildPreviewDeploymentRecord, + buildPreviewRecord, + buildProductionDeploymentRecord, + getExplicitPreviewSyncOverrides, + getPreviewDeploymentId, + getVersionInfoById, + hasRetireSelector, + markDeploymentRecordDeleted, + markPreviewScopeRecordDeleted, + markPreviewRecordDeleted, + matchesPreviewScopeRetireTarget, + matchesPreviewDeploymentRetireTarget, + matchesPreviewRetireTarget +} from './preview-registry-records' +import { + clearPreviewRegistrySchemaCache, + ensurePreviewRegistrySchema, + isMissingRegistrySchemaError, + isUnavailableRegistryContextError, + readDeploymentRows, + readPreviewScopeRows, + readPreviewRows, + upsertDeploymentRecord, + upsertPreviewScopeRecord, + upsertPreviewRecord +} from './preview-registry-store' +import type { + CleanupPreviewRegistryOptions, + CleanupPreviewRegistryResult, + ListTrackedRecordsOptions, + ListTrackedRegistryStateOptions, + PreviewRegistryContext, + ReconcilePreviewRegistryOptions, + ReconcilePreviewRegistryResult, + RetirePreviewRegistryOptions, + RetirePreviewRegistryResult +} from './preview-registry-types' + +export { DEVFLARE_PREVIEW_REGISTRY_DATABASE } from './preview-registry-types' +export type { + CleanupPreviewRegistryOptions, + CleanupPreviewRegistryResult, + ListTrackedRecordsOptions, + ListTrackedRegistryStateOptions, + PreviewRegistryContext, + ReconcilePreviewRegistryOptions, + ReconcilePreviewRegistryResult, + RetirePreviewRegistryOptions, + RetirePreviewRegistryResult +} from './preview-registry-types' + +async function withRegistryReadRecovery( + registry: PreviewRegistryContext, + apiOptions: APIClientOptions | undefined, + operation: (activeRegistry: PreviewRegistryContext) => Promise +): Promise<{ registry: PreviewRegistryContext; result: T }> { + try { + return { + registry, + result: await operation(registry) + } + } catch (error) { + if (isMissingRegistrySchemaError(error)) { + clearPreviewRegistrySchemaCache(registry.databaseId) + await ensurePreviewRegistrySchema(registry, apiOptions) + return { + registry, + result: await operation(registry) + } + } + + if (!isUnavailableRegistryContextError(error)) { + throw error + } + + clearCachedPreviewRegistryContext(registry.accountId, registry.databaseName) + const refreshedRegistry = await ensurePreviewRegistry({ + accountId: registry.accountId, + databaseName: registry.databaseName, + apiOptions, + skipContextCache: true + }) + + return { + registry: refreshedRegistry, + result: await operation(refreshedRegistry) + } + } +} + +async function loadTrackedRegistryRows( + registry: PreviewRegistryContext, + workerName: string | undefined, + apiOptions?: APIClientOptions +): Promise<{ + previews: Awaited> + scopes: Awaited> + deployments: Awaited> +}> { + const [previews, scopes, deployments] = await Promise.all([ + readPreviewRows(registry, workerName, apiOptions), + readPreviewScopeRows(registry, workerName, apiOptions), + readDeploymentRows(registry, workerName, apiOptions) + ]) + + return { + previews, + scopes, + deployments + } +} + +async function applyDeletedRecords( + registry: PreviewRegistryContext, + options: { + previews: Awaited> + scopes: Awaited> + deployments: Awaited> + now: Date + apiOptions?: APIClientOptions + } +): Promise { + for (const preview of options.previews) { + await upsertPreviewRecord(registry, markPreviewRecordDeleted(preview, options.now), options.apiOptions) + } + + for (const scope of options.scopes) { + await upsertPreviewScopeRecord(registry, markPreviewScopeRecordDeleted(scope, options.now), options.apiOptions) + } + + for (const deployment of options.deployments) { + await upsertDeploymentRecord(registry, markDeploymentRecordDeleted(deployment, options.now), options.apiOptions) + } +} + +export async function getPreviewRegistryContext(options: { + accountId: string + databaseName?: string + apiOptions?: APIClientOptions + skipContextCache?: boolean +}): Promise { + const databaseName = getRegistryDatabaseName(options.databaseName) + if (options.skipContextCache !== true) { + const cached = getCachedPreviewRegistryContext(options.accountId, databaseName) + if (cached) { + return cached + } + } + + const databases = await listD1Databases(options.accountId, options.apiOptions) + const existing = databases.find((database) => database.name === databaseName) + + if (!existing) { + return null + } + + const registry = { + accountId: options.accountId, + databaseId: existing.id, + databaseName, + created: false + } + cachePreviewRegistryContext(registry) + return registry +} + +export async function ensurePreviewRegistry(options: { + accountId: string + databaseName?: string + apiOptions?: APIClientOptions + logger?: { info?: (message: string) => void } + skipSchemaIfExisting?: boolean + skipContextCache?: boolean +}): Promise { + const existingContext = await getPreviewRegistryContext(options) + let registry = existingContext + + if (!registry) { + const created = await createD1Database( + options.accountId, + getRegistryDatabaseName(options.databaseName), + options.apiOptions + ) + registry = { + accountId: options.accountId, + databaseId: created.id, + databaseName: created.name, + created: true + } + cachePreviewRegistryContext(registry) + options.logger?.info?.(`Created Devflare preview registry D1 database: ${registry.databaseName}`) + } + + if (registry.created || options.skipSchemaIfExisting !== true) { + await ensurePreviewRegistrySchema(registry, options.apiOptions) + } + + return registry +} + +export async function listTrackedRegistryState( + options: ListTrackedRegistryStateOptions +): Promise<{ + previews: Awaited> + scopes: Awaited> + deployments: Awaited> +}> { + const { result } = await withRegistryReadRecovery(options.registry, options.apiOptions, async (registry) => { + return loadTrackedRegistryRows(registry, options.workerName, options.apiOptions) + }) + + return result +} + +export async function listTrackedPreviewRecords( + options: ListTrackedRecordsOptions +): Promise<{ registry: PreviewRegistryContext; records: Awaited> }> { + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + skipSchemaIfExisting: true + }) + + const { registry: resolvedRegistry, result } = await withRegistryReadRecovery( + registry, + options.apiOptions, + (activeRegistry) => readPreviewRows(activeRegistry, options.workerName, options.apiOptions) + ) + + return { + registry: resolvedRegistry, + records: result + } +} + +export async function listTrackedPreviewScopeRecords( + options: ListTrackedRecordsOptions +): Promise<{ registry: PreviewRegistryContext; records: Awaited> }> { + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + skipSchemaIfExisting: true + }) + + const { registry: resolvedRegistry, result } = await withRegistryReadRecovery( + registry, + options.apiOptions, + (activeRegistry) => readPreviewScopeRows(activeRegistry, options.workerName, options.apiOptions) + ) + + return { + registry: resolvedRegistry, + records: result + } +} + +export async function listTrackedDeploymentRecords( + options: ListTrackedRecordsOptions +): Promise<{ registry: PreviewRegistryContext; records: Awaited> }> { + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + skipSchemaIfExisting: true + }) + + const { registry: resolvedRegistry, result } = await withRegistryReadRecovery( + registry, + options.apiOptions, + (activeRegistry) => readDeploymentRows(activeRegistry, options.workerName, options.apiOptions) + ) + + return { + registry: resolvedRegistry, + records: result + } +} + +export async function reconcilePreviewRegistry( + options: ReconcilePreviewRegistryOptions +): Promise { + const now = options.now ?? new Date() + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + logger: options.logger + }) + const workersSubdomain = await getWorkersSubdomain(options.accountId, options.apiOptions) + const liveVersions = await listWorkerVersions(options.accountId, options.workerName, options.apiOptions) + const liveDeployments = await listWorkerDeployments(options.accountId, options.workerName, options.apiOptions) + const { previews: previewRecords, scopes: scopeRecords, deployments: deploymentRecords } = await loadTrackedRegistryRows( + registry, + options.workerName, + options.apiOptions + ) + const previewRecordByVersionId = new Map(previewRecords.map((record) => [record.versionId, record])) + const previewScopeRecordByScope = new Map(scopeRecords.map((record) => [record.scope, record])) + const deploymentRecordById = new Map(deploymentRecords.map((record) => [record.deploymentId, record])) + const syncedPreviews: typeof previewRecords = [] + const syncedScopes: typeof scopeRecords = [] + const syncedDeployments: typeof deploymentRecords = [] + const versionMetadataMap = new Map(liveVersions.map((version) => [version.id, version])) + const previewVersions = [...liveVersions.filter((candidate) => candidate.metadata.hasPreview)] + + if ( + options.versionId + && (options.previewUrl || options.previewScopeUrl || options.previewScope) + && !previewVersions.some((version) => version.id === options.versionId) + ) { + const explicitPreviewVersion = await getVersionInfoById( + options.accountId, + options.workerName, + options.versionId, + versionMetadataMap, + options.apiOptions + ) + + if (explicitPreviewVersion) { + previewVersions.unshift({ + ...explicitPreviewVersion, + metadata: { + ...explicitPreviewVersion.metadata, + hasPreview: true + } + }) + } + } + + for (const version of previewVersions) { + const previewRecord = buildPreviewRecord({ + accountId: options.accountId, + workerName: options.workerName, + version, + existing: previewRecordByVersionId.get(version.id), + workersSubdomain, + ...getExplicitPreviewSyncOverrides(options, version.id), + source: options.source, + now + }) + + if (!previewRecord) { + options.logger?.warn?.(`Skipping preview registry sync for ${version.id} because no preview URL could be determined.`) + continue + } + + await upsertPreviewRecord(registry, previewRecord, options.apiOptions) + syncedPreviews.push(previewRecord) + + const scopeRecord = buildPreviewScopeRecord({ + accountId: options.accountId, + workerName: options.workerName, + previewRecord, + existing: previewRecord.scope ? previewScopeRecordByScope.get(previewRecord.scope) : undefined, + now + }) + + if (scopeRecord) { + await upsertPreviewScopeRecord(registry, scopeRecord, options.apiOptions) + syncedScopes.push(scopeRecord) + } + + const previewDeploymentRecord = buildPreviewDeploymentRecord({ + accountId: options.accountId, + workerName: options.workerName, + previewRecord, + existing: deploymentRecordById.get(getPreviewDeploymentId(options.workerName, previewRecord.versionId)), + now + }) + await upsertDeploymentRecord(registry, previewDeploymentRecord, options.apiOptions) + syncedDeployments.push(previewDeploymentRecord) + } + + for (const [index, deployment] of liveDeployments.entries()) { + const versionId = deployment.versions[0]?.versionId + const version = versionId + ? await getVersionInfoById( + options.accountId, + options.workerName, + versionId, + versionMetadataMap, + options.apiOptions + ) + : undefined + const deploymentRecord = buildProductionDeploymentRecord({ + accountId: options.accountId, + workerName: options.workerName, + deployment, + version, + existing: deploymentRecordById.get(deployment.id), + workersSubdomain, + source: options.source, + commitSha: versionId === options.versionId ? options.commitSha : undefined, + deploymentMessage: index === 0 ? options.deploymentMessage : undefined, + status: index === 0 ? 'active' : 'superseded', + now + }) + + if (!deploymentRecord) { + continue + } + + await upsertDeploymentRecord(registry, deploymentRecord, options.apiOptions) + syncedDeployments.push(deploymentRecord) + } + + return { + registry, + previews: syncedPreviews, + previewScopes: syncedScopes, + deployments: syncedDeployments + } +} + +export async function cleanupPreviewRegistry( + options: CleanupPreviewRegistryOptions +): Promise { + const now = options.now ?? new Date() + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + logger: options.logger + }) + const { previews, scopes, deployments } = await loadTrackedRegistryRows(registry, options.workerName, options.apiOptions) + const cutoff = new Date(now.getTime() - Math.max(options.days ?? 7, 0) * 24 * 60 * 60 * 1000) + const previewCandidates = previews.filter((record) => !record.deletedAt && record.createdAt <= cutoff && record.status !== 'active') + const scopeCandidates = scopes.filter((record) => !record.deletedAt && record.createdAt <= cutoff && record.status !== 'active') + const deploymentCandidates = deployments.filter((record) => !record.deletedAt && record.createdAt <= cutoff && record.status !== 'active') + + if (options.apply) { + await applyDeletedRecords(registry, { + previews: previewCandidates, + scopes: scopeCandidates, + deployments: deploymentCandidates, + now, + apiOptions: options.apiOptions + }) + } + + return { + registry, + previews, + scopes, + deployments, + candidates: { + previews: previewCandidates, + scopes: scopeCandidates, + deployments: deploymentCandidates + }, + applied: options.apply === true + } +} + +export async function retirePreviewRegistry( + options: RetirePreviewRegistryOptions +): Promise { + if (!hasRetireSelector(options)) { + throw new Error('Retiring preview registry records requires at least one selector: branchName, previewScope, versionId, or commitSha.') + } + + const now = options.now ?? new Date() + const registry = await ensurePreviewRegistry({ + accountId: options.accountId, + databaseName: options.databaseName, + apiOptions: options.apiOptions, + logger: options.logger + }) + const { previews, scopes, deployments } = await loadTrackedRegistryRows(registry, options.workerName, options.apiOptions) + + const directlyMatchedPreviews = previews.filter((record) => !record.deletedAt && matchesPreviewRetireTarget(record, options)) + const directlyMatchedScopes = scopes.filter((record) => !record.deletedAt && matchesPreviewScopeRetireTarget(record, options)) + const directlyMatchedDeployments = deployments.filter((record) => !record.deletedAt && matchesPreviewDeploymentRetireTarget(record, options)) + + const candidatePreviewIds = new Set([ + ...directlyMatchedPreviews.map((record) => record.id), + ...directlyMatchedScopes.flatMap((record) => record.previewId ? [record.previewId] : []) + ]) + const candidateVersionIds = new Set([ + ...directlyMatchedPreviews.map((record) => record.versionId), + ...directlyMatchedScopes.map((record) => record.versionId), + ...directlyMatchedDeployments.map((record) => record.versionId), + ...(options.versionId ? [options.versionId] : []) + ]) + + const previewCandidates = previews.filter((record) => { + return !record.deletedAt + && ( + matchesPreviewRetireTarget(record, options) + || candidatePreviewIds.has(record.id) + || candidateVersionIds.has(record.versionId) + ) + }) + + const resolvedPreviewIds = new Set(previewCandidates.map((record) => record.id)) + const resolvedVersionIds = new Set([ + ...candidateVersionIds, + ...previewCandidates.map((record) => record.versionId) + ]) + + const scopeCandidates = scopes.filter((record) => { + return !record.deletedAt + && ( + matchesPreviewScopeRetireTarget(record, options) + || resolvedVersionIds.has(record.versionId) + || (record.previewId !== undefined && resolvedPreviewIds.has(record.previewId)) + ) + }) + + for (const record of scopeCandidates) { + resolvedVersionIds.add(record.versionId) + if (record.previewId) { + resolvedPreviewIds.add(record.previewId) + } + } + + const deploymentCandidates = deployments.filter((record) => { + return !record.deletedAt + && record.channel === 'preview' + && ( + matchesPreviewDeploymentRetireTarget(record, options) + || resolvedVersionIds.has(record.versionId) + || (record.previewId !== undefined && resolvedPreviewIds.has(record.previewId)) + ) + }) + + if (options.apply) { + await applyDeletedRecords(registry, { + previews: previewCandidates, + scopes: scopeCandidates, + deployments: deploymentCandidates, + now, + apiOptions: options.apiOptions + }) + } + + return { + registry, + previews, + scopes, + deployments, + candidates: { + previews: previewCandidates, + scopes: scopeCandidates, + deployments: deploymentCandidates + }, + applied: options.apply === true + } +} diff --git a/packages/devflare/src/cloudflare/preview-urls.ts b/packages/devflare/src/cloudflare/preview-urls.ts new file mode 100644 index 0000000..247581c --- /dev/null +++ b/packages/devflare/src/cloudflare/preview-urls.ts @@ -0,0 +1,27 @@ +function normalizeWorkersSubdomain(accountSubdomain: string): string { + return accountSubdomain + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\.workers\.dev\/?$/i, '') +} + +export function formatWorkersDevUrl( + workerName: string, + accountSubdomain: string +): string { + const normalizedSubdomain = normalizeWorkersSubdomain(accountSubdomain) + + return `https://${workerName}.${normalizedSubdomain}.workers.dev` +} + +export function formatVersionPreviewUrl( + versionId: string, + workerName: string, + accountSubdomain: string +): string { + const normalizedSubdomain = normalizeWorkersSubdomain(accountSubdomain) + + const versionPrefix = versionId.split('-')[0] || versionId + + return `https://${versionPrefix}-${workerName}.${normalizedSubdomain}.workers.dev` +} \ No newline at end of file diff --git a/packages/devflare/src/cloudflare/pricing.ts b/packages/devflare/src/cloudflare/pricing.ts new file mode 100644 index 0000000..25a758e --- /dev/null +++ b/packages/devflare/src/cloudflare/pricing.ts @@ -0,0 +1,34 @@ +// ============================================================================= +// AI Pricing โ€” Cloudflare Workers AI Pricing Reference +// ============================================================================= +// Pricing is NOT available via API. For current pricing, see: +// https://developers.cloudflare.com/workers-ai/platform/pricing/ +// +// Key facts: +// - Price: $0.011 per 1,000 neurons +// - Free tier: 10,000 neurons per day +// - Pricing varies per model (check docs for current rates) +// ============================================================================= + +/** Cloudflare Workers AI pricing documentation URL */ +export const PRICING_DOCS_URL = 'https://developers.cloudflare.com/workers-ai/platform/pricing/' + +/** Price per 1,000 neurons in USD (as of Jan 2026) */ +export const PRICE_PER_1000_NEURONS_USD = 0.011 + +/** Free tier neurons per day */ +export const FREE_TIER_NEURONS_PER_DAY = 10_000 + +/** + * Convert neurons to approximate USD cost + */ +export function neuronsToUSD(neurons: number): number { + return (neurons / 1000) * PRICE_PER_1000_NEURONS_USD +} + +/** + * Format a message about pricing with docs link + */ +export function getPricingInfo(): string { + return `Workers AI: $${PRICE_PER_1000_NEURONS_USD} per 1,000 neurons (${FREE_TIER_NEURONS_PER_DAY.toLocaleString()} free/day)\nFull pricing: ${PRICING_DOCS_URL}` +} diff --git a/packages/devflare/src/cloudflare/registry-schema.ts b/packages/devflare/src/cloudflare/registry-schema.ts new file mode 100644 index 0000000..103a80d --- /dev/null +++ b/packages/devflare/src/cloudflare/registry-schema.ts @@ -0,0 +1,164 @@ +// ============================================================================= +// Devflare Account-Layer Record Schemas +// ============================================================================= +// Zod 4 schemas for Devflare-managed metadata stored inside a user's Cloudflare +// account. These records are intended for a D1-first control-plane layer that +// tracks previews, scopes, deployments, and future reconciliation state. +// ============================================================================= + +import { z } from 'zod/v4' + +const recordIdSchema = z.string().min(1) +const cloudflareAccountIdSchema = z.string().min(1) +const cloudflareVersionIdSchema = z.string().uuid() +const workerNameSchema = z.string().min(1) +const timestampSchema = z.coerce.date() +const urlSchema = z.string().url() +const branchNameSchema = z.string().min(1) +const commitShaSchema = z.string().regex(/^[a-f0-9]{7,40}$/i, { + message: 'Commit SHA must be 7 to 40 hexadecimal characters' +}) +const previewScopeSchema = z.string().regex(/^[a-z][a-z0-9-]*$/, { + message: 'Preview names must start with a lowercase letter and contain only lowercase letters, numbers, and dashes' +}) + +// Cloudflare's API surfaces author/user identifiers as strings, but accepting a +// numeric-like input here keeps the schema ergonomic for future callers that may +// materialize user ids from numeric storage or UI forms. +export const cloudflareUserIdSchema = z.union([ + z.string().min(1), + z.number().int().nonnegative().transform((value) => String(value)) +]) + +export const devflareAccountRecordSchema = z.object({ + id: recordIdSchema, + ver: z.number().int().min(1), + createdAt: timestampSchema, + updatedAt: timestampSchema.optional(), + deletedAt: timestampSchema.optional(), + createdBy: cloudflareUserIdSchema +}).strict() + +export function createDevflareAccountRecordSchema(shape: Shape) { + return devflareAccountRecordSchema.extend(shape) +} + +export const devflareRecordSourceSchema = z.enum([ + 'cli', + 'github-action', + 'workers-builds', + 'dashboard', + 'unknown' +]) + +export const devflarePreviewStatusSchema = z.enum([ + 'active', + 'superseded', + 'orphaned', + 'deleted' +]) + +export const devflarePreviewScopeStatusSchema = z.enum([ + 'active', + 'reassigned', + 'deleted' +]) + +export const devflareDeploymentChannelSchema = z.enum([ + 'production', + 'preview' +]) + +export const devflareDeploymentStatusSchema = z.enum([ + 'active', + 'superseded', + 'rolled_back', + 'deleted' +]) + +export const devflarePreviewRecordSchema = createDevflareAccountRecordSchema({ + kind: z.literal('preview'), + accountId: cloudflareAccountIdSchema, + workerName: workerNameSchema, + versionId: cloudflareVersionIdSchema, + previewUrl: urlSchema, + scope: previewScopeSchema.optional(), + scopeUrl: urlSchema.optional(), + branchName: branchNameSchema.optional(), + commitSha: commitShaSchema.optional(), + deploymentId: recordIdSchema.optional(), + source: devflareRecordSourceSchema.default('unknown'), + status: devflarePreviewStatusSchema.default('active') +}).superRefine((record, ctx) => { + if (record.scopeUrl && !record.scope) { + ctx.addIssue({ + code: 'custom', + path: ['scopeUrl'], + message: 'scopeUrl requires scope to be set' + }) + } +}) + +export const devflarePreviewScopeRecordSchema = createDevflareAccountRecordSchema({ + kind: z.literal('previewScope'), + accountId: cloudflareAccountIdSchema, + workerName: workerNameSchema, + scope: previewScopeSchema, + scopeUrl: urlSchema, + versionId: cloudflareVersionIdSchema, + previewId: recordIdSchema.optional(), + branchName: branchNameSchema.optional(), + commitSha: commitShaSchema.optional(), + source: devflareRecordSourceSchema.default('unknown'), + status: devflarePreviewScopeStatusSchema.default('active') +}) + +export const devflareDeploymentRecordSchema = createDevflareAccountRecordSchema({ + kind: z.literal('deployment'), + accountId: cloudflareAccountIdSchema, + workerName: workerNameSchema, + deploymentId: recordIdSchema, + channel: devflareDeploymentChannelSchema, + status: devflareDeploymentStatusSchema.default('active'), + versionId: cloudflareVersionIdSchema, + previewId: recordIdSchema.optional(), + environment: z.string().min(1).optional(), + url: urlSchema.optional(), + message: z.string().min(1).optional(), + commitSha: commitShaSchema.optional(), + source: devflareRecordSourceSchema.default('unknown') +}).superRefine((record, ctx) => { + if (record.channel === 'preview' && !record.previewId) { + ctx.addIssue({ + code: 'custom', + path: ['previewId'], + message: 'Preview deployments must reference the preview record they materialize' + }) + } + + if (record.channel === 'production' && record.previewId) { + ctx.addIssue({ + code: 'custom', + path: ['previewId'], + message: 'Production deployments should not reference previewId' + }) + } +}) + +export const devflareAccountLayerRecordSchema = z.discriminatedUnion('kind', [ + devflarePreviewRecordSchema, + devflarePreviewScopeRecordSchema, + devflareDeploymentRecordSchema +]) + +export type CloudflareUserId = z.output +export type DevflareAccountRecord = z.output +export type DevflareRecordSource = z.output +export type DevflarePreviewStatus = z.output +export type DevflarePreviewScopeStatus = z.output +export type DevflareDeploymentChannel = z.output +export type DevflareDeploymentStatus = z.output +export type DevflarePreviewRecord = z.output +export type DevflarePreviewScopeRecord = z.output +export type DevflareDeploymentRecord = z.output +export type DevflareAccountLayerRecord = z.output diff --git a/packages/devflare/src/cloudflare/remote-config.ts b/packages/devflare/src/cloudflare/remote-config.ts new file mode 100644 index 0000000..1ba0a77 --- /dev/null +++ b/packages/devflare/src/cloudflare/remote-config.ts @@ -0,0 +1,198 @@ +// ============================================================================= +// Remote Mode Configuration +// ============================================================================= +// Stores remote mode settings locally to avoid setting env vars every time. +// File location: ~/.devflare/remote.json +// ============================================================================= + +import { homedir } from 'os' +import { join } from 'path' +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs' + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/** Minimum duration in minutes */ +const MIN_MINUTES = 1 +/** Maximum duration in minutes (24 hours) */ +const MAX_MINUTES = 1440 + +interface RemoteConfig { + /** Unix timestamp (ms) when remote mode expires */ + expiresAt: number + /** When remote mode was enabled */ + enabledAt: number + /** Duration in minutes */ + durationMinutes: number +} + +// ----------------------------------------------------------------------------- +// Config Path +// ----------------------------------------------------------------------------- + +function getConfigDir(): string { + return join(homedir(), '.devflare') +} + +function getConfigPath(): string { + return join(getConfigDir(), 'remote.json') +} + +// ----------------------------------------------------------------------------- +// Read/Write Config +// ----------------------------------------------------------------------------- + +function readConfig(): RemoteConfig | null { + const path = getConfigPath() + if (!existsSync(path)) return null + try { + const content = readFileSync(path, 'utf-8') + return JSON.parse(content) as RemoteConfig + } catch { + return null + } +} + +function writeConfig(config: RemoteConfig): void { + const dir = getConfigDir() + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(getConfigPath(), JSON.stringify(config, null, '\t')) +} + +function deleteConfig(): void { + const path = getConfigPath() + if (existsSync(path)) { + try { + unlinkSync(path) + } catch { + // File may be locked (Windows) or deleted by another process + } + } +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Enable remote mode for a given duration. + * @param minutes Duration in minutes (default: 30, clamped to 1-1440) + * @returns The validated minutes value actually used + */ +export function enableRemoteMode(minutes: number = 30): number { + // Validate and clamp + const validMinutes = Math.max(MIN_MINUTES, Math.min(MAX_MINUTES, Math.floor(minutes) || 30)) + + const now = Date.now() + writeConfig({ + enabledAt: now, + expiresAt: now + (validMinutes * 60 * 1000), + durationMinutes: validMinutes + }) + + return validMinutes +} + +/** + * Disable remote mode immediately. + */ +export function disableRemoteMode(): void { + deleteConfig() +} + +/** + * Get the current remote mode status from stored config. + * Note: Use getEffectiveRemoteModeStatus() to also include env var override. + * @returns Status object with isEnabled, remainingMinutes, and expiresAt + */ +export function getRemoteModeStatus(): { + isEnabled: boolean + remainingMinutes: number + expiresAt: Date | null +} { + const config = readConfig() + if (!config) { + return { isEnabled: false, remainingMinutes: 0, expiresAt: null } + } + + const now = Date.now() + if (now >= config.expiresAt) { + // Expired - clean up + deleteConfig() + return { isEnabled: false, remainingMinutes: 0, expiresAt: null } + } + + const remainingMs = config.expiresAt - now + const remainingMinutes = Math.ceil(remainingMs / 60000) + + return { + isEnabled: true, + remainingMinutes, + expiresAt: new Date(config.expiresAt) + } +} + +/** + * Get the effective remote mode status including env var override. + * @returns Status object with isActive, source, and config details + */ +export function getEffectiveRemoteModeStatus(): { + isActive: boolean + source: 'env' | 'config' | 'none' + remainingMinutes: number + expiresAt: Date | null + envVarSet: boolean +} { + // Check env var first + const envValue = process.env.DEVFLARE_REMOTE ?? '' + const envVarSet = ['1', 'true', 'yes'].includes(envValue.toLowerCase()) + + if (envVarSet) { + return { + isActive: true, + source: 'env', + remainingMinutes: Infinity, + expiresAt: null, + envVarSet: true + } + } + + // Check stored config + const status = getRemoteModeStatus() + if (status.isEnabled) { + return { + isActive: true, + source: 'config', + remainingMinutes: status.remainingMinutes, + expiresAt: status.expiresAt, + envVarSet: false + } + } + + return { + isActive: false, + source: 'none', + remainingMinutes: 0, + expiresAt: null, + envVarSet: false + } +} + +/** + * Check if remote mode is currently enabled. + * Checks both env var (DEVFLARE_REMOTE=1) and stored config. + */ +export function isRemoteModeActive(): boolean { + // Check env var first (for CI/CD or explicit override) + const envValue = process.env.DEVFLARE_REMOTE ?? '' + if (['1', 'true', 'yes'].includes(envValue.toLowerCase())) { + return true + } + + // Check stored config + const status = getRemoteModeStatus() + return status.isEnabled +} diff --git a/packages/devflare/src/cloudflare/tokens.ts b/packages/devflare/src/cloudflare/tokens.ts new file mode 100644 index 0000000..9382437 --- /dev/null +++ b/packages/devflare/src/cloudflare/tokens.ts @@ -0,0 +1,509 @@ +import { apiDelete, apiGetAll, apiPost, apiPut, type APIClientOptions } from './api' +import { KNOWN_PERMISSION_GROUP_IDS_DATA } from './known-permission-group-ids.generated' +import type { + AccountOwnedAPIToken, + AccountOwnedAPITokenDeleteResult, + AccountOwnedAPITokenPolicy, + AccountTokenPermissionGroup +} from './types' + +const MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS = 300 +export const DEVFLARE_MANAGED_TOKEN_PREFIX = 'devflare-' +const ACCOUNT_OWNED_TOKEN_SCOPE = 'com.cloudflare.api.account' +const ACCOUNT_ZONE_OWNED_TOKEN_SCOPE = 'com.cloudflare.api.account.zone' + +const ACCOUNT_API_TOKENS_PERMISSION_GROUP_NAME_PATTERN = /^Account API Tokens\b/i +const DEVFLARE_MANAGED_TOKEN_NAME_PATTERN = /^devflare-/i + +// Devflare-managed tokens must include every variant (Read / Write / Edit / +// Admin / Metadata Read / etc.) of every product permission group it touches. +// Cloudflare's REST endpoints often distinguish read vs. edit vs. admin +// operations on the *same* resource (e.g. listing R2 buckets requires the +// "Workers R2 Storage" read permission while creating a bucket requires the +// edit permission), so granting only one variant deterministically breaks +// downstream provisioning. Each pattern below intentionally matches the full +// product family (`/^Product /i`) rather than a specific verb, so any new +// variant Cloudflare publishes โ€” including new admin tiers โ€” gets picked up +// automatically when the token is (re-)created. +const DEVFLARE_PERMISSION_GROUP_NAME_PATTERNS = [ + /^Account Analytics /i, + /^Account Settings /i, + /^Account Filter Lists /i, + /^AI /i, + /^Analytics /i, + /^Browser Rendering /i, + /^Cache Purge\b/i, + /^D1 /i, + /^DNS /i, + /^Email /i, + /^Hyperdrive /i, + /^Images /i, + /^Logs /i, + /^Logpush /i, + /^Pages /i, + /^Queues /i, + /^R2 /i, + /^SSL and Certificates /i, + /^Stream /i, + /^Vectorize /i, + /^Workers /i, + /^Zone Settings /i, + /^Zone /i +] as const + +/** + * Symbolic names for individual Cloudflare permission groups we care about + * when reasoning about a Devflare token policy. Stable ids (when known) + * should be preferred over display names, which Cloudflare is free to + * rename or localize at any time. + * + * Entries set to `undefined` have not been confidently verified against + * Cloudflare's `GET /accounts/:id/tokens/permission_groups` endpoint and + * fall back to exact display-name matching via + * {@link KNOWN_PERMISSION_GROUP_DISPLAY_NAMES}. + * + * The verified UUIDs (or `null` placeholders) live in + * `known-permission-group-ids.generated.ts`, which is rewritten by + * `scripts/refresh-permission-groups.ts` against a maintainer's Cloudflare + * account so this file does not need hand-edits when Cloudflare publishes + * or rotates permission-group ids. + */ +function deriveKnownPermissionGroupIds( + data: Record +): Record { + const result = {} as Record + for (const key of Object.keys(data) as TKey[]) { + const value = data[key] + result[key] = value === null ? undefined : value + } + return result +} + +export const KNOWN_PERMISSION_GROUP_IDS = deriveKnownPermissionGroupIds( + KNOWN_PERMISSION_GROUP_IDS_DATA +) satisfies Record + +/** + * Canonical display names used for exact-match fallback when the + * corresponding id in {@link KNOWN_PERMISSION_GROUP_IDS} is not verified. + * + * Exact matches only โ€” no substring / case-insensitive matching โ€” so + * drift (e.g. Cloudflare adding a suffix or renaming) is caught rather + * than silently mis-matching. + */ +export const KNOWN_PERMISSION_GROUP_DISPLAY_NAMES: Record< + keyof typeof KNOWN_PERMISSION_GROUP_IDS, + string +> = { + WORKERS_SCRIPTS_WRITE: 'Workers Scripts Write', + WORKERS_SCRIPTS_READ: 'Workers Scripts Read', + ACCOUNT_SETTINGS_READ: 'Account Settings Read', + WORKERS_KV_STORAGE_WRITE: 'Workers KV Storage Write', + WORKERS_KV_STORAGE_READ: 'Workers KV Storage Read', + ACCOUNT_API_TOKENS_WRITE: 'Account API Tokens Write', + ACCOUNT_API_TOKENS_READ: 'Account API Tokens Read' +} + +export type KnownPermissionGroupName = keyof typeof KNOWN_PERMISSION_GROUP_IDS + +/** + * Match a Cloudflare permission group against a known symbolic name, + * preferring the stable id and falling back to an *exact* display-name + * match. Logs a `console.warn` on fallback so drift is visible in logs. + * + * The `options` hook exists so callers (and tests) can supply their own + * id / display-name tables without mutating the module-level maps. + */ +export function matchesKnownPermissionGroup( + symbolicName: KnownPermissionGroupName, + permissionGroup: Pick, + options?: { + knownIds?: Record + knownDisplayNames?: Record + } +): boolean { + const knownIds = options?.knownIds ?? KNOWN_PERMISSION_GROUP_IDS + const knownDisplayNames = options?.knownDisplayNames ?? KNOWN_PERMISSION_GROUP_DISPLAY_NAMES + + const expectedId = knownIds[symbolicName] + if (typeof expectedId === 'string' && expectedId.length > 0) { + return permissionGroup.id === expectedId + } + + const expectedName = knownDisplayNames[symbolicName] + if (typeof expectedName === 'string' && permissionGroup.name === expectedName) { + console.warn( + `[devflare] Matched Cloudflare permission group '${symbolicName}' by display name ` + + `('${expectedName}') because no verified id is configured. Cloudflare display ` + + 'names are unstable; please file an issue to add the permission-group id to ' + + 'KNOWN_PERMISSION_GROUP_IDS.' + ) + return true + } + + return false +} + +// Cloudflare lets a bootstrap token manage API tokens, but it does not allow the +// created sub-token to inherit token-management permissions. Devflare therefore +// uses the bootstrap token for minting and excludes Account API Tokens permissions +// from the resulting reusable Devflare token. + +interface RawAccountOwnedAPITokenPolicy { + id?: string + effect?: 'allow' | 'deny' + permission_groups?: Array<{ + id: string + name?: string + }> +} + +interface RawAccountOwnedAPIToken { + id: string + name?: string + status?: string + value?: string + issued_on?: string + modified_on?: string + last_used_on?: string + policies?: RawAccountOwnedAPITokenPolicy[] +} + +interface AccountOwnedAPITokenCreatePolicy { + effect: 'allow' + resources: Record + permission_groups: Array<{ id: string }> +} + +type ScopedPermissionGroup = Pick + +function dedupePermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + const seenIds = new Set() + + return permissionGroups.filter((permissionGroup) => { + if (seenIds.has(permissionGroup.id)) { + return false + } + + seenIds.add(permissionGroup.id) + return true + }) +} + +function dedupePermissionGroupIds(permissionGroupIds: string[]): string[] { + return Array.from(new Set(permissionGroupIds.map((id) => id.trim()).filter(Boolean))) +} + +function dedupeScopedPermissionGroups( + permissionGroups: ScopedPermissionGroup[] +): ScopedPermissionGroup[] { + const seenIds = new Set() + + return permissionGroups.filter((permissionGroup) => { + const id = permissionGroup.id.trim() + if (!id || seenIds.has(id)) { + return false + } + + seenIds.add(id) + return true + }) +} + +function permissionGroupHasScope( + permissionGroup: ScopedPermissionGroup, + scope: string +): boolean { + return permissionGroup.scopes.some((value) => value.trim() === scope) +} + +function buildCreateTokenPoliciesFromPermissionGroups( + accountId: string, + permissionGroups: ScopedPermissionGroup[] +): AccountOwnedAPITokenCreatePolicy[] { + const dedupedPermissionGroups = dedupeScopedPermissionGroups(permissionGroups) + const accountPermissionGroupIds = dedupePermissionGroupIds( + dedupedPermissionGroups + .filter((permissionGroup) => permissionGroupHasScope(permissionGroup, ACCOUNT_OWNED_TOKEN_SCOPE)) + .map((permissionGroup) => permissionGroup.id) + ) + const zonePermissionGroupIds = dedupePermissionGroupIds( + dedupedPermissionGroups + .filter((permissionGroup) => permissionGroupHasScope(permissionGroup, ACCOUNT_ZONE_OWNED_TOKEN_SCOPE)) + .map((permissionGroup) => permissionGroup.id) + ) + const policies: AccountOwnedAPITokenCreatePolicy[] = [] + + if (accountPermissionGroupIds.length > 0) { + policies.push({ + effect: 'allow', + resources: { + [`com.cloudflare.api.account.${accountId}`]: '*' + }, + permission_groups: accountPermissionGroupIds.map((id) => ({ id })) + }) + } + + if (zonePermissionGroupIds.length > 0) { + policies.push({ + effect: 'allow', + resources: { + [`com.cloudflare.api.account.${accountId}`]: { + [`${ACCOUNT_ZONE_OWNED_TOKEN_SCOPE}.*`]: '*' + } + }, + permission_groups: zonePermissionGroupIds.map((id) => ({ id })) + }) + } + + return policies +} + +function buildCreateTokenPoliciesFromPermissionGroupIds( + accountId: string, + permissionGroupIds: string[] +): AccountOwnedAPITokenCreatePolicy[] { + const dedupedPermissionGroupIds = dedupePermissionGroupIds(permissionGroupIds) + if (dedupedPermissionGroupIds.length === 0) { + return [] + } + + return [ + { + effect: 'allow', + resources: { + [`com.cloudflare.api.account.${accountId}`]: '*' + }, + permission_groups: dedupedPermissionGroupIds.map((id) => ({ id })) + } + ] +} + +function excludeAccountApiTokensPermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + return permissionGroups.filter((permissionGroup) => { + return !ACCOUNT_API_TOKENS_PERMISSION_GROUP_NAME_PATTERN.test(permissionGroup.name) + }) +} + +function keepAccountOwnedTokenCompatiblePermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + return permissionGroups.filter((permissionGroup) => { + return permissionGroup.scopes.some((scope) => { + const normalizedScope = scope.trim() + return normalizedScope === ACCOUNT_OWNED_TOKEN_SCOPE + || normalizedScope === ACCOUNT_ZONE_OWNED_TOKEN_SCOPE + }) + }) +} + +function selectReusableAccountOwnedTokenPermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + return dedupePermissionGroups( + excludeAccountApiTokensPermissionGroups( + keepAccountOwnedTokenCompatiblePermissionGroups(permissionGroups) + ) + ) +} + +function parseOptionalDate(value?: string): Date | undefined { + if (!value) { + return undefined + } + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? undefined : parsed +} + +function mapAccountOwnedAPITokenPolicy( + policy: RawAccountOwnedAPITokenPolicy +): AccountOwnedAPITokenPolicy { + return { + id: policy.id, + effect: policy.effect, + permissionGroups: policy.permission_groups?.map((permissionGroup) => ({ + id: permissionGroup.id, + name: permissionGroup.name + })) + } +} + +function mapAccountOwnedAPIToken(token: RawAccountOwnedAPIToken): AccountOwnedAPIToken { + return { + id: token.id, + name: token.name ?? token.id, + status: token.status, + value: token.value, + issuedOn: parseOptionalDate(token.issued_on), + modifiedOn: parseOptionalDate(token.modified_on), + lastUsedOn: parseOptionalDate(token.last_used_on), + policies: token.policies?.map(mapAccountOwnedAPITokenPolicy) + } +} + +export function isDevflareManagedTokenName(name: string): boolean { + return DEVFLARE_MANAGED_TOKEN_NAME_PATTERN.test(name.trim()) +} + +export function normalizeDevflareTokenName(name: string): string { + const trimmedName = name.trim() + if (!trimmedName) { + throw new Error('Devflare token name cannot be empty') + } + + const suffix = isDevflareManagedTokenName(trimmedName) + ? trimmedName.replace(DEVFLARE_MANAGED_TOKEN_NAME_PATTERN, '') + : trimmedName + + if (!suffix) { + throw new Error('Devflare token name cannot be empty') + } + + return `${DEVFLARE_MANAGED_TOKEN_PREFIX}${suffix}` +} + +export function stripDevflareTokenNamePrefix(name: string): string { + const trimmedName = name.trim() + if (!trimmedName) { + return trimmedName + } + + const strippedName = trimmedName.replace(DEVFLARE_MANAGED_TOKEN_NAME_PATTERN, '') + return strippedName || trimmedName +} + +export function filterDevflareManagedTokens( + tokens: AccountOwnedAPIToken[] +): AccountOwnedAPIToken[] { + return tokens.filter((token) => isDevflareManagedTokenName(token.name)) +} + +export function selectDevflarePermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + const selectedPermissionGroups = dedupePermissionGroups(selectReusableAccountOwnedTokenPermissionGroups(permissionGroups).filter((permissionGroup) => { + return DEVFLARE_PERMISSION_GROUP_NAME_PATTERNS.some((pattern) => { + return pattern.test(permissionGroup.name) + }) + })) + + if (selectedPermissionGroups.length === 0) { + throw new Error( + 'Could not map the available Cloudflare permission groups to a Devflare token policy.' + ) + } + + if (selectedPermissionGroups.length > MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS) { + throw new Error( + `Devflare selected ${selectedPermissionGroups.length} permission groups, which exceeds Cloudflare's ${MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS}-group limit for account-owned tokens.` + ) + } + + return selectedPermissionGroups +} + +export function selectAllReusablePermissionGroups( + permissionGroups: AccountTokenPermissionGroup[] +): AccountTokenPermissionGroup[] { + const selectedPermissionGroups = selectReusableAccountOwnedTokenPermissionGroups(permissionGroups) + + if (selectedPermissionGroups.length === 0) { + throw new Error( + 'Could not find any reusable account/zone-scoped Cloudflare permission groups for this Devflare token.' + ) + } + + if (selectedPermissionGroups.length > MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS) { + throw new Error( + `Devflare selected ${selectedPermissionGroups.length} permission groups, which exceeds Cloudflare\'s ${MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS}-group limit for account-owned tokens.` + ) + } + + return selectedPermissionGroups +} + +export async function listAccountTokenPermissionGroups( + accountId: string, + options?: APIClientOptions +): Promise { + const permissionGroups = await apiGetAll( + `/accounts/${accountId}/tokens/permission_groups`, + options + ) + + return dedupePermissionGroups(permissionGroups) +} + +export async function listAccountOwnedAPITokens( + accountId: string, + options?: APIClientOptions +): Promise { + const tokens = await apiGetAll(`/accounts/${accountId}/tokens`, options) + return tokens.map(mapAccountOwnedAPIToken) +} + +export async function deleteAccountOwnedAPIToken( + accountId: string, + tokenId: string, + options?: APIClientOptions +): Promise { + return apiDelete(`/accounts/${accountId}/tokens/${tokenId}`, options) +} + +export async function rollAccountOwnedAPITokenValue( + accountId: string, + tokenId: string, + options?: APIClientOptions +): Promise { + return apiPut(`/accounts/${accountId}/tokens/${tokenId}/value`, {}, options) +} + +export async function createAccountOwnedAPIToken( + accountId: string, + options: { + name: string + permissionGroupIds?: string[] + permissionGroups?: ScopedPermissionGroup[] + }, + clientOptions?: APIClientOptions +): Promise { + const permissionGroupIds = dedupePermissionGroupIds( + options.permissionGroups?.map((permissionGroup) => permissionGroup.id) + ?? options.permissionGroupIds + ?? [] + ) + + if (permissionGroupIds.length === 0) { + throw new Error('Cannot create a Devflare token without any permission groups') + } + + if (permissionGroupIds.length > MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS) { + throw new Error( + `Cannot create a Devflare token with more than ${MAX_ACCOUNT_OWNED_TOKEN_PERMISSION_GROUPS} permission groups.` + ) + } + + const policies = options.permissionGroups + ? buildCreateTokenPoliciesFromPermissionGroups(accountId, options.permissionGroups) + : buildCreateTokenPoliciesFromPermissionGroupIds(accountId, permissionGroupIds) + + if (policies.length === 0) { + throw new Error('Cannot create a Devflare token without any account- or zone-scoped permission groups') + } + + const createdToken = await apiPost( + `/accounts/${accountId}/tokens`, + { + name: options.name, + policies + }, + clientOptions + ) + + return mapAccountOwnedAPIToken(createdToken) +} diff --git a/packages/devflare/src/cloudflare/types.ts b/packages/devflare/src/cloudflare/types.ts new file mode 100644 index 0000000..b83d21c --- /dev/null +++ b/packages/devflare/src/cloudflare/types.ts @@ -0,0 +1,386 @@ +// ============================================================================= +// Cloudflare API Types +// ============================================================================= +// Type definitions for Cloudflare API responses and internal usage +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Account Types +// ----------------------------------------------------------------------------- + +export interface CloudflareAccount { + id: string + name: string + type: 'standard' | 'enterprise' | string + settings?: { + enforce_twofactor?: boolean + use_account_custom_ns_by_default?: boolean + } + created_on?: string +} + +export interface AccountInfo { + id: string + name: string + type: string + createdOn?: Date +} + +// ----------------------------------------------------------------------------- +// Service Types +// ----------------------------------------------------------------------------- + +export type CloudflareService = + | 'workers' + | 'kv' + | 'd1' + | 'r2' + | 'ai' + | 'ai_search' + | 'ai_gateway' + | 'vectorize' + | 'durable_objects' + | 'queues' + | 'hyperdrive' + | 'browser' + | 'media' + | 'mtls_certificates' + | 'artifacts' + | 'builds' + +export interface ServiceStatus { + service: CloudflareService + available: boolean + count?: number + details?: Record +} + +// ----------------------------------------------------------------------------- +// Worker Types +// ----------------------------------------------------------------------------- + +export interface WorkerScript { + id: string + name?: string // Some APIs return 'name', others use 'id' as the name + created_on: string + modified_on: string + etag?: string +} + +export interface WorkerInfo { + name: string + createdOn: Date + modifiedOn: Date +} + +export interface WorkerVersionMetadata { + authorEmail?: string + authorId?: string + createdOn?: Date + modifiedOn?: Date + hasPreview: boolean + source?: string +} + +export interface WorkerVersionInfo { + id: string + number?: number + metadata: WorkerVersionMetadata +} + +export interface WorkerDeploymentVersion { + percentage: number + versionId: string +} + +export interface WorkerDeploymentInfo { + id: string + createdOn: Date + source: string + strategy: string + versions: WorkerDeploymentVersion[] + message?: string + triggeredBy?: string + authorEmail?: string +} + +// ----------------------------------------------------------------------------- +// KV Types +// ----------------------------------------------------------------------------- + +export interface KVNamespace { + id: string + title: string + supports_url_encoding?: boolean +} + +export interface KVNamespaceInfo { + id: string + name: string +} + +// ----------------------------------------------------------------------------- +// D1 Types +// ----------------------------------------------------------------------------- + +export interface D1Database { + uuid: string + name: string + version: string + num_tables?: number + file_size?: number + created_at?: string +} + +export interface D1DatabaseInfo { + id: string + name: string + version?: string + tableCount?: number + sizeBytes?: number +} + +// ----------------------------------------------------------------------------- +// Queue Types +// ----------------------------------------------------------------------------- + +export interface Queue { + queue_id?: string + queue_name?: string + created_on?: string + modified_on?: string + settings?: { + delivery_delay?: number + delivery_paused?: boolean + message_retention_period?: number + } +} + +export interface QueueInfo { + id: string + name: string + createdOn?: Date + modifiedOn?: Date + deliveryDelay?: number + deliveryPaused?: boolean + messageRetentionPeriod?: number +} + +// ----------------------------------------------------------------------------- +// Hyperdrive Types +// ----------------------------------------------------------------------------- + +export interface HyperdriveConfig { + id: string + name: string + created_on?: string + modified_on?: string +} + +export interface HyperdriveConfigInfo { + id: string + name: string + createdOn?: Date + modifiedOn?: Date +} + +export type D1QueryParameter = string | number | boolean | null + +export interface D1QueryMeta { + changedDb?: boolean + changes?: number + duration?: number + lastRowId?: number + rowsRead?: number + rowsWritten?: number + servedByColo?: string + servedByPrimary?: boolean + servedByRegion?: string + sizeAfter?: number + timings?: { + sqlDurationMs?: number + } +} + +export interface D1QueryResult> { + meta?: D1QueryMeta + results?: T[] + success?: boolean +} + +export interface D1RawQueryResult { + meta?: D1QueryMeta + results?: { + columns?: string[] + rows?: Array>> + } + success?: boolean +} + +// ----------------------------------------------------------------------------- +// R2 Types +// ----------------------------------------------------------------------------- + +export interface R2Bucket { + name: string + creation_date: string + location?: string +} + +export interface R2BucketInfo { + name: string + createdOn: Date + location?: string +} + +// ----------------------------------------------------------------------------- +// Vectorize Types +// ----------------------------------------------------------------------------- + +export interface VectorizeIndex { + name: string + description?: string + config: { + dimensions: number + metric: 'cosine' | 'euclidean' | 'dot-product' + } + created_on?: string + modified_on?: string +} + +export interface VectorizeIndexInfo { + name: string + dimensions: number + metric: string + description?: string +} + +// ----------------------------------------------------------------------------- +// AI Types +// ----------------------------------------------------------------------------- + +export interface AIModel { + id: string + name: string + description?: string + task?: { + id: string + name: string + description?: string + } + properties?: Array<{ + property_id: string + value: string + }> +} + +export interface AIModelInfo { + id: string + name: string + task?: string + description?: string +} + +// ----------------------------------------------------------------------------- +// Usage & Limits Types +// ----------------------------------------------------------------------------- + +export interface UsageRecord { + service: CloudflareService + /** ISO date string (YYYY-MM-DD) */ + date: string + /** Usage count (requests, tokens, bytes, etc.) */ + count: number + /** Last updated timestamp */ + updatedAt: string +} + +export interface UsageLimits { + /** Daily limit for AI tokens (across all models) */ + aiTokensPerDay?: number + /** Daily limit for AI requests */ + aiRequestsPerDay?: number + /** Daily limit for Vectorize operations */ + vectorizeOpsPerDay?: number + /** Whether limits are enabled */ + enabled: boolean +} + +export interface UsageSummary { + service: CloudflareService + today: number + limit?: number + withinLimit: boolean + percentUsed?: number +} + +// ----------------------------------------------------------------------------- +// API Response Types +// ----------------------------------------------------------------------------- + +export interface CloudflareAPIResponse { + success: boolean + errors: Array<{ code: number; message: string }> + messages: Array<{ code: number; message: string }> + result: T + result_info?: { + page?: number + per_page?: number + total_pages?: number + count?: number + total_count?: number + cursor?: string + } +} + +// ----------------------------------------------------------------------------- +// Auth Types +// ----------------------------------------------------------------------------- + +export interface WranglerAuth { + /** OAuth token from wrangler config */ + oauthToken?: string + /** API token (if explicitly set) */ + apiToken?: string + /** Refresh token for OAuth */ + refreshToken?: string + /** Token expiry time */ + expiresAt?: Date +} + +// ----------------------------------------------------------------------------- +// API Token Types +// ----------------------------------------------------------------------------- + +export interface AccountTokenPermissionGroup { + id: string + name: string + description?: string + scopes: string[] +} + +export interface AccountOwnedAPITokenPermissionGroup { + id: string + name?: string +} + +export interface AccountOwnedAPITokenPolicy { + id?: string + effect?: 'allow' | 'deny' + permissionGroups?: AccountOwnedAPITokenPermissionGroup[] +} + +export interface AccountOwnedAPIToken { + id: string + name: string + status?: string + value?: string + issuedOn?: Date + modifiedOn?: Date + lastUsedOn?: Date + policies?: AccountOwnedAPITokenPolicy[] +} + +export interface AccountOwnedAPITokenDeleteResult { + id: string +} diff --git a/packages/devflare/src/cloudflare/usage.ts b/packages/devflare/src/cloudflare/usage.ts new file mode 100644 index 0000000..6ecfb78 --- /dev/null +++ b/packages/devflare/src/cloudflare/usage.ts @@ -0,0 +1,471 @@ +// ============================================================================= +// Usage Tracking & Limits Module +// ============================================================================= +// Tracks API usage and enforces limits to prevent unexpected costs +// Storage: Devflare-managed KV namespace in user's Cloudflare account +// ============================================================================= + +import { kvGet, kvPut } from './api' +import { DEVFLARE_KV_NAMESPACE_TITLE, getOrCreateNamedKVNamespace } from './kv-namespace' +import type { + CloudflareService, + UsageLimits, + UsageRecord, + UsageSummary +} from './types' + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +const USAGE_KEY_PREFIX = 'usage:' +const LIMITS_KEY = 'limits' + +// Default limits (can be overridden by user) +const DEFAULT_LIMITS: UsageLimits = { + aiTokensPerDay: 10000, // 10k tokens per day for testing + aiRequestsPerDay: 100, // 100 AI requests per day + vectorizeOpsPerDay: 1000, // 1000 vectorize ops per day + enabled: true +} + +// ----------------------------------------------------------------------------- +// KV Namespace Management +// ----------------------------------------------------------------------------- + +/** + * Find or create the devflare-managed KV namespace + */ +async function getOrCreateUsageNamespace(accountId: string): Promise { + return getOrCreateNamedKVNamespace(accountId, DEVFLARE_KV_NAMESPACE_TITLE) +} + +// ----------------------------------------------------------------------------- +// Usage Tracking +// ----------------------------------------------------------------------------- + +/** + * Get today's date in ISO format (YYYY-MM-DD) + */ +function getTodayDate(): string { + return new Date().toISOString().split('T')[0] +} + +/** + * Build the usage key for a service and date + */ +function buildUsageKey(service: CloudflareService, date: string): string { + return `${USAGE_KEY_PREFIX}${service}:${date}` +} + +/** + * Get usage for a specific service on a specific date + */ +export async function getUsage( + accountId: string, + service: CloudflareService, + date?: string +): Promise { + const targetDate = date ?? getTodayDate() + const namespaceId = await getOrCreateUsageNamespace(accountId) + const key = buildUsageKey(service, targetDate) + + const value = await kvGet(accountId, namespaceId, key) + + if (value === null) { + return null + } + + try { + return JSON.parse(value) as UsageRecord + } catch { + // If parsing fails, the stored value is corrupt; treat as not found + return null + } +} + +/** + * Optional injection points for {@link recordUsage}. + * + * Cloudflare's KV REST API does not expose an atomic compare-and-swap + * primitive, so recording usage is implemented as an optimistic read-modify- + * write loop with post-write verification. These dependencies are exposed + * primarily for testing the retry path. + */ +export interface RecordUsageDeps { + kvGet?: typeof kvGet + kvPut?: typeof kvPut + getNamespaceId?: (accountId: string) => Promise + sleep?: (ms: number) => Promise + now?: () => Date + maxAttempts?: number + warn?: (message: string) => void +} + +const MAX_RECORD_USAGE_ATTEMPTS = 5 + +/** + * Record usage for a service. + * + * Usage counts are recorded via an optimistic read-modify-write loop against + * a Devflare-managed KV namespace. After each write the value is re-read and + * compared against the update we just issued; if another writer clobbered it + * we back off and retry, capped at {@link MAX_RECORD_USAGE_ATTEMPTS} attempts. + * + * Because Cloudflare KV is eventually consistent and lacks conditional writes, + * the counters are inherently best-effort โ€” under heavy concurrency some + * increments can still be lost. When the retry budget is exhausted we emit a + * warning instead of silently dropping the update. + */ +export async function recordUsage( + accountId: string, + service: CloudflareService, + count: number = 1, + deps: RecordUsageDeps = {} +): Promise { + const kvGetFn = deps.kvGet ?? kvGet + const kvPutFn = deps.kvPut ?? kvPut + const getNamespaceId = deps.getNamespaceId ?? getOrCreateUsageNamespace + const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const now = deps.now ?? (() => new Date()) + const maxAttempts = deps.maxAttempts ?? MAX_RECORD_USAGE_ATTEMPTS + const warn = deps.warn ?? ((message: string) => console.warn(message)) + + const today = now().toISOString().split('T')[0] + const namespaceId = await getNamespaceId(accountId) + const key = buildUsageKey(service, today) + + let lastWritten: UsageRecord | null = null + let lastObserved: UsageRecord | null = null + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const existingRaw = await kvGetFn(accountId, namespaceId, key) + let existing: UsageRecord | null = null + if (existingRaw !== null) { + try { + existing = JSON.parse(existingRaw) as UsageRecord + } catch { + existing = null + } + } + lastObserved = existing + + const record: UsageRecord = { + service, + date: today, + count: (existing?.count ?? 0) + count, + updatedAt: now().toISOString() + } + + await kvPutFn(accountId, namespaceId, key, JSON.stringify(record)) + lastWritten = record + + // Verify: read-after-write. If our update is intact, we're done. + // Note: KV is eventually consistent so this verification is best-effort. + const verifyRaw = await kvGetFn(accountId, namespaceId, key) + if (verifyRaw !== null) { + try { + const verify = JSON.parse(verifyRaw) as UsageRecord + if (verify.updatedAt === record.updatedAt && verify.count === record.count) { + return record + } + } catch { + // fall through to retry + } + } + + // A concurrent writer clobbered our update (or the read is stale). + // Back off and retry by re-reading and re-applying our delta on top. + if (attempt < maxAttempts - 1) { + const backoffMs = Math.min(25 * 2 ** attempt, 400) + await sleep(backoffMs) + } + } + + warn( + `[devflare] recordUsage: could not confirm usage write for ${service} after ${maxAttempts} attempts ` + + 'due to concurrent writes; usage counts are best-effort under concurrency.' + ) + + return lastWritten ?? { + service, + date: today, + count: (lastObserved?.count ?? 0) + count, + updatedAt: now().toISOString() + } +} + +/** + * Reset usage for a service (typically called when limits are adjusted) + */ +export async function resetUsage( + accountId: string, + service: CloudflareService +): Promise { + const today = getTodayDate() + const namespaceId = await getOrCreateUsageNamespace(accountId) + const key = buildUsageKey(service, today) + + const record: UsageRecord = { + service, + date: today, + count: 0, + updatedAt: new Date().toISOString() + } + + await kvPut(accountId, namespaceId, key, JSON.stringify(record)) +} + +// ----------------------------------------------------------------------------- +// Limits Management +// ----------------------------------------------------------------------------- + +/** + * Get the current usage limits + */ +export async function getLimits(accountId: string): Promise { + const namespaceId = await getOrCreateUsageNamespace(accountId) + const value = await kvGet(accountId, namespaceId, LIMITS_KEY) + + if (value === null) { + return DEFAULT_LIMITS + } + + try { + return { ...DEFAULT_LIMITS, ...JSON.parse(value) } + } catch { + return DEFAULT_LIMITS + } +} + +/** + * Update usage limits + */ +export async function setLimits( + accountId: string, + limits: Partial +): Promise { + const namespaceId = await getOrCreateUsageNamespace(accountId) + const current = await getLimits(accountId) + + const updated: UsageLimits = { + ...current, + ...limits + } + + await kvPut(accountId, namespaceId, LIMITS_KEY, JSON.stringify(updated)) + + return updated +} + +/** + * Enable or disable limits enforcement + */ +export async function setLimitsEnabled( + accountId: string, + enabled: boolean +): Promise { + return setLimits(accountId, { enabled }) +} + +// ----------------------------------------------------------------------------- +// Usage Checks +// ----------------------------------------------------------------------------- + +/** + * Check if usage is within limits for a service + */ +export async function isWithinLimits( + accountId: string, + service: CloudflareService +): Promise { + const limits = await getLimits(accountId) + + // If limits are disabled, always within limits + if (!limits.enabled) { + return true + } + + const usage = await getUsage(accountId, service) + const currentCount = usage?.count ?? 0 + + switch (service) { + case 'ai': + // Check request limits (token tracking would require more complex integration) + if (limits.aiRequestsPerDay && currentCount >= limits.aiRequestsPerDay) { + return false + } + return true + + case 'vectorize': + if (limits.vectorizeOpsPerDay && currentCount >= limits.vectorizeOpsPerDay) { + return false + } + return true + + default: + // No limits defined for other services + return true + } +} + +/** + * Get usage summary for a service + */ +export async function getUsageSummary( + accountId: string, + service: CloudflareService +): Promise { + const limits = await getLimits(accountId) + const usage = await getUsage(accountId, service) + const currentCount = usage?.count ?? 0 + + let limit: number | undefined + switch (service) { + case 'ai': + limit = limits.aiRequestsPerDay + break + case 'vectorize': + limit = limits.vectorizeOpsPerDay + break + } + + const withinLimit = limit === undefined || currentCount < limit + const percentUsed = limit ? (currentCount / limit) * 100 : undefined + + return { + service, + today: currentCount, + limit, + withinLimit, + percentUsed + } +} + +/** + * Get usage summary for all tracked services + */ +export async function getAllUsageSummaries(accountId: string): Promise { + const trackedServices: CloudflareService[] = ['ai', 'vectorize'] + + return Promise.all( + trackedServices.map((s) => getUsageSummary(accountId, s)) + ) +} + +// ----------------------------------------------------------------------------- +// Pre-test Check +// ----------------------------------------------------------------------------- + +/** + * Check if we can proceed with testing for a specific service + * Returns true if within limits, false if limits exceeded + * + * Use this before running tests that use remote bindings + */ +export async function canProceedWithTest( + accountId: string, + service: CloudflareService +): Promise<{ allowed: boolean; reason?: string }> { + const limits = await getLimits(accountId) + + if (!limits.enabled) { + return { allowed: true } + } + + const withinLimits = await isWithinLimits(accountId, service) + + if (!withinLimits) { + const summary = await getUsageSummary(accountId, service) + return { + allowed: false, + reason: `Daily limit exceeded for ${service}: ${summary.today}/${summary.limit} (${summary.percentUsed?.toFixed(1)}%)` + } + } + + return { allowed: true } +} + +/** + * Record that a test used a remote service + * Call this after successful test execution + */ +export async function recordTestUsage( + accountId: string, + service: CloudflareService, + count: number = 1 +): Promise { + await recordUsage(accountId, service, count) +} + +// ----------------------------------------------------------------------------- +// Simplified Skip Check for Tests +// ----------------------------------------------------------------------------- + +// Import auth and account functions for skip check +import { isAuthenticated } from './auth' +import { getPrimaryAccount } from './account' +import { getEffectiveAccountId } from './preferences' + +/** + * Check if tests for a service should be skipped + * + * Returns `true` if tests should be SKIPPED (service not available) + * Returns `false` if tests can proceed + * + * Automatically logs the skip reason to console. + * + * NOTE: This function is read-only and catches all errors gracefully. + * If Cloudflare is unreachable, auth fails, or limits can't be checked, + * it will return true (skip) with an appropriate message. + * + * Usage: + * ```ts + * import { account } from 'devflare/cloudflare' + * + * const skipAI = await account.shouldSkip('ai') + * + * describe.skipIf(skipAI)('AI tests', () => { + * // ... + * }) + * ``` + */ +export async function shouldSkip(service: CloudflareService): Promise { + try { + // 1. Check authentication + const isAuth = await isAuthenticated() + if (!isAuth) { + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: Not authenticated. Run: bunx wrangler login`) + return true + } + + // 2. Get effective account ID + const primary = await getPrimaryAccount() + if (!primary) { + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: No Cloudflare account found`) + return true + } + + const { accountId } = await getEffectiveAccountId(primary.id) + + // 3. Check usage limits (read-only: skip if namespace doesn't exist or check fails) + try { + const { allowed, reason } = await canProceedWithTest(accountId, service) + if (!allowed) { + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: ${reason}`) + return true + } + } catch { + // If limits can't be checked (e.g., KV not set up), allow the test to run + // The user hasn't configured limits, so we assume they want to run tests + } + + // All checks passed - don't skip + return false + } catch (error) { + // Gracefully skip on any error (network issues, API errors, etc.) + const message = error instanceof Error ? error.message : 'Unknown error' + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: ${message}`) + return true + } +} diff --git a/packages/devflare/src/config-entry.ts b/packages/devflare/src/config-entry.ts new file mode 100644 index 0000000..8bbd5a2 --- /dev/null +++ b/packages/devflare/src/config-entry.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// Devflare โ€” Config-Only Public Entry +// ============================================================================= +// Use this from devflare.config.ts files to avoid loading the full Node-side +// package barrel (CLI, bridge, test helpers, transforms, etc.) just to access +// defineConfig() or ref(). +// ============================================================================= + +export { + defineConfig, + type DefineConfigInput, + type TypedConfig +} from './config/define' + +export { + env, + type EnvVarDescriptor, + type InferConfigVars +} from './config/env-vars' + +export { + preview, + type PreviewScopeFn, + type PreviewScopeOptions, + type PreviewScopedName, + type PreviewScopedNameOptions +} from './config/preview' + +export { + ref, + type RefResult, + type WorkerBinding, + type WorkerBindingAccessor, + type DOBindingRef +} from './config/ref' + +export type * from './config/schema-types' + +export { defineConfig as default } from './config/define' diff --git a/packages/devflare/src/config/binding-resolution-helpers.ts b/packages/devflare/src/config/binding-resolution-helpers.ts new file mode 100644 index 0000000..c1f6434 --- /dev/null +++ b/packages/devflare/src/config/binding-resolution-helpers.ts @@ -0,0 +1,157 @@ +// ============================================================================= +// Shared binding-resolution helpers (R1 step 2 โ€” collapse duplicate helpers) +// ============================================================================= +// `resource-resolution.ts` (build/local/automation) and `deploy-resources.ts` +// (deploy with optional auto-provisioning) historically each carried their +// own copy of the same KV/D1/Hyperdrive name-binding plumbing. This module +// hosts the truly shared, side-effect-free helpers so both pipelines reuse +// one implementation. Side-effecting bits (account-id resolution chain, +// list-or-create) remain in their respective files because their failure +// modes and error messages diverge. +// ============================================================================= + +import { + normalizeD1Binding, + normalizeHyperdriveBinding, + normalizeKVBinding, + type DevflareConfig +} from './schema' + +export interface NormalizedNameBinding { + id?: string + name?: string +} + +export interface PendingNameBinding { + bindingName: string + resourceName: string +} + +type KVBindings = NonNullable['kv']> +type D1Bindings = NonNullable['d1']> +type HyperdriveBindings = NonNullable['hyperdrive']> + +export function normalizeKVNameBinding(bindingConfig: KVBindings[string]): NormalizedNameBinding { + const normalized = normalizeKVBinding(bindingConfig) + return { + id: normalized.namespaceId, + name: normalized.name + } +} + +export function normalizeD1NameBinding(bindingConfig: D1Bindings[string]): NormalizedNameBinding { + const normalized = normalizeD1Binding(bindingConfig) + return { + id: normalized.databaseId, + name: normalized.name + } +} + +export function normalizeHyperdriveNameBinding( + bindingConfig: HyperdriveBindings[string] +): NormalizedNameBinding { + const normalized = normalizeHyperdriveBinding(bindingConfig) + return { + id: normalized.configurationId, + name: normalized.name + } +} + +export function materializeIdBindings( + bindings: Record, + resolveId: (binding: TBinding) => string +): Record { + return Object.fromEntries( + Object.entries(bindings).map(([bindingName, bindingConfig]) => { + return [bindingName, { id: resolveId(bindingConfig) }] + }) + ) +} + +export function materializeHyperdriveIdBindings( + bindings: HyperdriveBindings | undefined, + idsByName?: Map +): Record | undefined { + if (!bindings) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings).map(([bindingName, bindingConfig]) => { + const normalized = normalizeHyperdriveBinding(bindingConfig) + return [ + bindingName, + { + id: normalized.configurationId + ?? idsByName?.get(normalized.name ?? '') + ?? normalized.name + ?? '', + ...(normalized.localConnectionString && { + localConnectionString: normalized.localConnectionString + }) + } + ] + }) + ) +} + +export function collectPendingNameBindings( + bindings: Record | undefined, + normalizeBinding: (binding: TBinding) => NormalizedNameBinding +): PendingNameBinding[] { + if (!bindings) { + return [] + } + + return Object.entries(bindings) + .map(([bindingName, bindingConfig]) => { + const normalized = normalizeBinding(bindingConfig) + return normalized.id + ? null + : { + bindingName, + resourceName: normalized.name ?? '' + } + }) + .filter((binding): binding is PendingNameBinding => binding !== null) +} + +export function materializeResolvedNameBindings( + bindings: Record | undefined, + normalizeBinding: (binding: TBinding) => NormalizedNameBinding, + idsByName: Map +): Record | undefined { + if (!bindings) { + return undefined + } + + return materializeIdBindings(bindings, (bindingConfig) => { + const normalized = normalizeBinding(bindingConfig) + return normalized.id ?? idsByName.get(normalized.name ?? '') ?? '' + }) +} + +export function withResolvedIdBindings( + resolvedConfig: DevflareConfig, + bindings: { + kv?: Record + d1?: Record + hyperdrive?: Record + } +): DevflareConfig { + return { + ...resolvedConfig, + bindings: { + ...resolvedConfig.bindings, + ...(bindings.kv ? { kv: bindings.kv } : {}), + ...(bindings.d1 ? { d1: bindings.d1 } : {}), + ...(bindings.hyperdrive ? { hyperdrive: bindings.hyperdrive } : {}) + } + } +} + +export function formatMissingBindings(missing: PendingNameBinding[]): string { + return missing + .map(({ bindingName, resourceName }) => `${bindingName} โ†’ ${resourceName}`) + .join(', ') +} diff --git a/packages/devflare/src/config/compatibility.ts b/packages/devflare/src/config/compatibility.ts new file mode 100644 index 0000000..8a47edf --- /dev/null +++ b/packages/devflare/src/config/compatibility.ts @@ -0,0 +1,5 @@ +export const FORCED_COMPATIBILITY_FLAGS = ['nodejs_compat', 'nodejs_als'] + +export function normalizeCompatibilityFlags(flags: string[] = []): string[] { + return [...new Set([...FORCED_COMPATIBILITY_FLAGS, ...flags])] +} \ No newline at end of file diff --git a/packages/devflare/src/config/compiler.ts b/packages/devflare/src/config/compiler.ts new file mode 100644 index 0000000..e3cb278 --- /dev/null +++ b/packages/devflare/src/config/compiler.ts @@ -0,0 +1,187 @@ +import { normalizeCompatibilityFlags } from './compatibility' +import { compileBindings } from './compiler/bindings' +import { + compileContainers, + compileModuleOptions, + compileWranglerMigrations +} from './compiler/core-helpers' +import type { CompileConfigOptions, WranglerConfig } from './compiler/types' +import { toWranglerSecretsConfig } from './local-dev-vars' +import { resolveConfigForEnvironment } from './resolve' +import type { ResolvedConfig } from './resolve-phased' +import type { DevflareConfig } from './schema' + +export { compileDOWorkerConfig } from './compiler/do-workers' +export { + isolateViteBuildOutputPaths, + readWranglerConfig, + rebaseWranglerConfigPaths, + stringifyConfig, + writeWranglerConfig +} from './compiler/paths' +export type { + CompileConfigOptions, + WranglerConfig, + WranglerD1DatabaseBinding, + WranglerHyperdriveBinding, + WranglerKVNamespaceBinding, + WranglerModuleRule +} from './compiler/types' + +/** + * Compile a phase-resolved DevflareConfig to WranglerConfig. + * + * R1 step 3: input is type-narrowed to `ResolvedConfig` (`LocalConfig | DeployConfig`) + * so callers cannot accidentally pass a raw `DevflareConfig` whose KV/D1/Hyperdrive + * bindings might still be name-only. The runtime throw remains as a defense-in-depth + * guard for callers that bypass the type system. + * + * @param config - A phase-resolved devflare configuration (`LocalConfig` or `DeployConfig`) + * @param environment - Optional environment name for env-specific overrides + * @returns Wrangler-compatible configuration object + */ +export function compileConfig(config: ResolvedConfig, environment?: string): WranglerConfig { + return compileConfigInternal(config, environment) +} + +export function compileBuildConfig( + config: DevflareConfig, + environment?: string, + options: { alreadyResolved?: boolean } = {} +): WranglerConfig { + return compileConfigInternal(config, environment, { + preserveNamedBindings: true, + alreadyResolved: options.alreadyResolved + }) +} + +function compileConfigInternal( + config: DevflareConfig, + environment?: string, + options: CompileConfigOptions = {} +): WranglerConfig { + const resolvedConfig = options.alreadyResolved + ? config + : resolveConfigForEnvironment(config, environment) + const mergedConfig = { + ...resolvedConfig, + compatibilityFlags: normalizeCompatibilityFlags(resolvedConfig.compatibilityFlags) + } + + const result: WranglerConfig = { + name: mergedConfig.name, + compatibility_date: mergedConfig.compatibilityDate, + preview_urls: true, + workers_dev: true + } + + if (mergedConfig.accountId) { + result.account_id = mergedConfig.accountId + } + + const mainEntry = mergedConfig.files?.fetch + if (typeof mainEntry === 'string') { + result.main = mainEntry + } + + compileModuleOptions(mergedConfig, result) + + if (mergedConfig.compatibilityFlags && mergedConfig.compatibilityFlags.length > 0) { + result.compatibility_flags = mergedConfig.compatibilityFlags + } + + if (mergedConfig.bindings) { + compileBindings(mergedConfig.bindings, result, options, mergedConfig.secretsStoreId) + } + + if (mergedConfig.triggers?.crons && mergedConfig.triggers.crons.length > 0) { + result.triggers = { crons: mergedConfig.triggers.crons } + } + + if (mergedConfig.tailConsumers && mergedConfig.tailConsumers.length > 0) { + result.tail_consumers = mergedConfig.tailConsumers.map((consumer) => + typeof consumer === 'string' + ? { service: consumer } + : { + service: consumer.service, + ...(consumer.environment && { environment: consumer.environment }) + } + ) + } + + if (mergedConfig.vars && Object.keys(mergedConfig.vars).length > 0) { + result.vars = mergedConfig.vars + } + + const secrets = toWranglerSecretsConfig(mergedConfig.secrets) + if (secrets) { + result.secrets = secrets + } + + if (mergedConfig.routes && mergedConfig.routes.length > 0) { + result.routes = mergedConfig.routes.map((route) => ({ + pattern: route.pattern, + ...(route.zone_name && { zone_name: route.zone_name }), + ...(route.zone_id && { zone_id: route.zone_id }), + ...(route.custom_domain !== undefined && { custom_domain: route.custom_domain }) + })) + } + + if (mergedConfig.assets?.directory) { + result.assets = { + directory: mergedConfig.assets.directory, + ...(mergedConfig.assets.binding && { binding: mergedConfig.assets.binding }), + ...(mergedConfig.assets.html_handling && { + html_handling: mergedConfig.assets.html_handling + }), + ...(mergedConfig.assets.not_found_handling && { + not_found_handling: mergedConfig.assets.not_found_handling + }), + ...(mergedConfig.assets.run_worker_first !== undefined && { + run_worker_first: mergedConfig.assets.run_worker_first + }) + } + } + + if (mergedConfig.placement) { + result.placement = mergedConfig.placement + } + + if (mergedConfig.observability) { + result.observability = mergedConfig.observability + } + + if (mergedConfig.limits) { + result.limits = mergedConfig.limits + } + + compileContainers(mergedConfig, result) + + if (mergedConfig.migrations && mergedConfig.migrations.length > 0) { + result.migrations = compileWranglerMigrations(mergedConfig.migrations) + } + + if (mergedConfig.wrangler?.passthrough) { + Object.assign(result, mergedConfig.wrangler.passthrough) + } + + return result +} + +/** + * Compile DevflareConfig to programmatic config for @cloudflare/vite-plugin. + * This is used instead of wrangler.jsonc in dev mode. + * + * @param config - The devflare configuration + * @param environment - Optional environment name for env-specific overrides + * @returns Config object compatible with cloudflare({ config: ... }) + */ +export function compileToProgrammaticConfig( + config: DevflareConfig, + environment?: string, + options: { preserveNamedBindings?: boolean } = {} +): Record { + return options.preserveNamedBindings + ? compileBuildConfig(config, environment) + : compileConfig(config as ResolvedConfig, environment) +} diff --git a/packages/devflare/src/config/compiler/bindings.ts b/packages/devflare/src/config/compiler/bindings.ts new file mode 100644 index 0000000..86425f1 --- /dev/null +++ b/packages/devflare/src/config/compiler/bindings.ts @@ -0,0 +1,424 @@ +import { + type D1Binding, + type DevflareConfig, + type HyperdriveBinding, + type KVBinding, + browserBindingSchema, + getSingleBrowserBindingName, + normalizeArtifactsBinding, + normalizeD1Binding, + normalizeDOBinding, + normalizeDispatchNamespaceBinding, + normalizeHyperdriveBinding, + normalizeImagesBinding, + normalizeKVBinding, + normalizeMediaBinding, + normalizeMtlsCertificateBinding, + normalizePipelineBinding, + normalizeSecretsStoreBinding, + normalizeWorkflowBinding +} from '../schema' +import type { + CompileConfigOptions, + WranglerConfig, + WranglerD1DatabaseBinding, + WranglerHyperdriveBinding, + WranglerKVNamespaceBinding +} from './types' + +export function getWranglerD1DatabaseBinding( + bindingName: string, + bindingConfig: D1Binding, + options: CompileConfigOptions = {} +): WranglerD1DatabaseBinding { + const normalized = normalizeD1Binding(bindingConfig) + if (normalized.databaseId) { + return { + binding: bindingName, + database_id: normalized.databaseId + } + } + + if (options.preserveNamedBindings && normalized.name) { + return { + binding: bindingName, + database_name: normalized.name + } + } + + throw new Error( + `D1 binding "${bindingName}" is configured by name (${normalized.name}) and must be resolved before compiling Wrangler config. Use loadResolvedConfig() or resolveConfigResources() for build/deploy/automation flows.` + ) +} + +export function getWranglerKVNamespaceBinding( + bindingName: string, + bindingConfig: KVBinding, + options: CompileConfigOptions = {} +): WranglerKVNamespaceBinding { + const normalized = normalizeKVBinding(bindingConfig) + if (normalized.namespaceId) { + return { + binding: bindingName, + id: normalized.namespaceId + } + } + + if (options.preserveNamedBindings && normalized.name) { + return { + binding: bindingName, + name: normalized.name + } + } + + throw new Error( + `KV binding "${bindingName}" is configured by name (${normalized.name}) and must be resolved before compiling Wrangler config. Use loadResolvedConfig() or resolveConfigResources() for build/deploy/automation flows.` + ) +} + +export function getWranglerHyperdriveBinding( + bindingName: string, + bindingConfig: HyperdriveBinding, + options: CompileConfigOptions = {} +): WranglerHyperdriveBinding { + const normalized = normalizeHyperdriveBinding(bindingConfig) + if (normalized.configurationId) { + return { + binding: bindingName, + id: normalized.configurationId, + ...(normalized.localConnectionString && { + localConnectionString: normalized.localConnectionString + }) + } + } + + if (options.preserveNamedBindings && normalized.name) { + return { + binding: bindingName, + name: normalized.name, + ...(normalized.localConnectionString && { + localConnectionString: normalized.localConnectionString + }) + } + } + + throw new Error( + `Hyperdrive binding "${bindingName}" is configured by name (${normalized.name}) and must be resolved before compiling Wrangler config. Use loadResolvedConfig() or resolveConfigResources() for build/deploy/automation flows.` + ) +} + +export function getWranglerBrowserBinding( + browserBindings: NonNullable['browser'] +): { binding: string; remote?: boolean } | undefined { + if (!browserBindings) { + return undefined + } + + // Re-validate via Zod so the canonical browser-binding-limit error is + // raised even when `compileConfig()` is called with input that bypassed + // `configSchema.parse()` (e.g. raw objects cast as DevflareConfig). + const parsed = browserBindingSchema.parse(browserBindings) + const bindingName = getSingleBrowserBindingName(parsed) + if (!bindingName) { + return undefined + } + + const bindingConfig = parsed[bindingName] + return { + binding: bindingName, + ...(typeof bindingConfig === 'object' && + bindingConfig.remote !== undefined && { + remote: bindingConfig.remote + }) + } +} + +/** + * Compile bindings from devflare format to wrangler format + */ +export function compileBindings( + bindings: NonNullable, + result: WranglerConfig, + options: CompileConfigOptions = {}, + defaultSecretsStoreId?: string +): void { + // KV Namespaces + if (bindings.kv) { + result.kv_namespaces = Object.entries(bindings.kv).map(([binding, namespace]) => { + return getWranglerKVNamespaceBinding(binding, namespace, options) + }) + } + + // D1 Databases + if (bindings.d1) { + result.d1_databases = Object.entries(bindings.d1).map(([binding, database_id]) => { + return getWranglerD1DatabaseBinding(binding, database_id, options) + }) + } + + // R2 Buckets + if (bindings.r2) { + result.r2_buckets = Object.entries(bindings.r2).map(([binding, bucket_name]) => ({ + binding, + bucket_name + })) + } + + // Durable Objects + if (bindings.durableObjects) { + result.durable_objects = { + bindings: Object.entries(bindings.durableObjects).map(([name, config]) => { + const normalized = normalizeDOBinding(config) + const binding: { name: string; class_name: string; script_name?: string } = { + name, + class_name: normalized.className + } + if (normalized.kind === 'cross-worker' && normalized.scriptName) { + binding.script_name = normalized.scriptName + } + return binding + }) + } + } + + // Queues + if (bindings.queues) { + result.queues = {} + + if (bindings.queues.producers) { + result.queues.producers = Object.entries(bindings.queues.producers).map( + ([binding, queue]) => ({ binding, queue }) + ) + } + + if (bindings.queues.consumers) { + result.queues.consumers = bindings.queues.consumers.map((consumer) => ({ + queue: consumer.queue, + ...(consumer.maxBatchSize && { max_batch_size: consumer.maxBatchSize }), + ...(consumer.maxBatchTimeout && { max_batch_timeout: consumer.maxBatchTimeout }), + ...(consumer.maxRetries && { max_retries: consumer.maxRetries }), + ...(consumer.deadLetterQueue && { dead_letter_queue: consumer.deadLetterQueue }), + ...(consumer.maxConcurrency && { max_concurrency: consumer.maxConcurrency }), + ...(consumer.retryDelay && { retry_delay: consumer.retryDelay }) + })) + } + } + + // Rate Limiting + if (bindings.rateLimits) { + result.ratelimits = Object.entries(bindings.rateLimits).map(([name, config]) => ({ + name, + namespace_id: config.namespaceId, + simple: { + limit: config.simple.limit, + period: config.simple.period + } + })) + } + + // Version Metadata + if (bindings.versionMetadata) { + result.version_metadata = { + binding: bindings.versionMetadata.binding + } + } + + // Worker Loaders + if (bindings.workerLoaders) { + result.worker_loaders = Object.keys(bindings.workerLoaders).map((binding) => ({ binding })) + } + + // mTLS Certificates + if (bindings.mtlsCertificates) { + result.mtls_certificates = Object.entries(bindings.mtlsCertificates).map( + ([binding, config]) => { + const normalized = normalizeMtlsCertificateBinding(config) + return { + binding, + certificate_id: normalized.certificateId, + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + } + ) + } + + // Dispatch Namespaces + if (bindings.dispatchNamespaces) { + result.dispatch_namespaces = Object.entries(bindings.dispatchNamespaces).map( + ([binding, config]) => { + const normalized = normalizeDispatchNamespaceBinding(config) + return { + binding, + namespace: normalized.namespace, + ...(normalized.outbound && { outbound: normalized.outbound }), + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + } + ) + } + + // Workflows + if (bindings.workflows) { + result.workflows = Object.entries(bindings.workflows).map(([binding, config]) => { + const normalized = normalizeWorkflowBinding(config) + return { + binding, + name: normalized.name, + class_name: normalized.className, + ...(normalized.scriptName && { script_name: normalized.scriptName }), + ...(normalized.remote !== undefined && { remote: normalized.remote }), + ...(normalized.limits && { limits: normalized.limits }) + } + }) + } + + // Pipelines + if (bindings.pipelines) { + result.pipelines = Object.entries(bindings.pipelines).map(([binding, config]) => { + const normalized = normalizePipelineBinding(config) + return { + binding, + pipeline: normalized.pipeline, + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + }) + } + + // Images + if (bindings.images) { + const [entry] = Object.entries(bindings.images) + if (entry) { + const [binding, config] = entry + const normalized = normalizeImagesBinding(binding, config) + result.images = { + binding: normalized.binding, + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + } + } + + // Media Transformations + if (bindings.media) { + const [entry] = Object.entries(bindings.media) + if (entry) { + const [binding, config] = entry + const normalized = normalizeMediaBinding(binding, config) + result.media = { + binding: normalized.binding, + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + } + } + + // Artifacts + if (bindings.artifacts) { + result.artifacts = Object.entries(bindings.artifacts).map(([binding, config]) => { + const normalized = normalizeArtifactsBinding(config) + return { + binding, + namespace: normalized.namespace, + ...(normalized.remote !== undefined && { remote: normalized.remote }) + } + }) + } + + // Secrets Store + if (bindings.secretsStore) { + result.secrets_store_secrets = Object.entries(bindings.secretsStore).map( + ([binding, config]) => { + const normalized = normalizeSecretsStoreBinding(config, defaultSecretsStoreId, binding) + return { + binding, + store_id: normalized.storeId, + secret_name: normalized.secretName + } + } + ) + } + + // Services + if (bindings.services) { + result.services = Object.entries(bindings.services).map(([binding, config]) => ({ + binding, + service: config.service, + ...(config.entrypoint && { entrypoint: config.entrypoint }), + ...(config.environment && { environment: config.environment }) + })) + } + + // AI + if (bindings.ai?.binding) { + result.ai = { + binding: bindings.ai.binding, + ...(bindings.ai.remote !== undefined && { remote: bindings.ai.remote }), + ...(bindings.ai.staging !== undefined && { staging: bindings.ai.staging }) + } + } + + // AI Search + if (bindings.aiSearchNamespaces) { + result.ai_search_namespaces = Object.entries(bindings.aiSearchNamespaces).map( + ([binding, config]) => ({ + binding, + namespace: config.namespace, + ...(config.remote !== undefined && { remote: config.remote }) + }) + ) + } + + if (bindings.aiSearch) { + result.ai_search = Object.entries(bindings.aiSearch).map(([binding, config]) => ({ + binding, + instance_name: config.instanceName, + ...(config.remote !== undefined && { remote: config.remote }) + })) + } + + // Vectorize + if (bindings.vectorize) { + result.vectorize = Object.entries(bindings.vectorize).map(([binding, config]) => ({ + binding, + index_name: config.indexName, + ...(config.remote !== undefined && { remote: config.remote }) + })) + } + + // Hyperdrive + if (bindings.hyperdrive) { + result.hyperdrive = Object.entries(bindings.hyperdrive).map(([binding, config]) => { + return getWranglerHyperdriveBinding(binding, config, options) + }) + } + + // Browser + const browserBinding = getWranglerBrowserBinding(bindings.browser) + if (browserBinding) { + result.browser = browserBinding + } + + // Analytics Engine + if (bindings.analyticsEngine) { + result.analytics_engine_datasets = Object.entries(bindings.analyticsEngine).map( + ([binding, config]) => ({ + binding, + dataset: config.dataset + }) + ) + } + + // Send Email + if (bindings.sendEmail) { + result.send_email = Object.entries(bindings.sendEmail).map(([name, config]) => ({ + name, + ...(config.destinationAddress && { + destination_address: config.destinationAddress + }), + ...(config.allowedDestinationAddresses && { + allowed_destination_addresses: config.allowedDestinationAddresses + }), + ...(config.allowedSenderAddresses && { + allowed_sender_addresses: config.allowedSenderAddresses + }) + })) + } +} diff --git a/packages/devflare/src/config/compiler/core-helpers.ts b/packages/devflare/src/config/compiler/core-helpers.ts new file mode 100644 index 0000000..8ee6c05 --- /dev/null +++ b/packages/devflare/src/config/compiler/core-helpers.ts @@ -0,0 +1,59 @@ +import type { DevflareConfig } from '../schema' +import type { WranglerConfig } from './types' + +export function compileWranglerMigrations( + migrations: NonNullable +): NonNullable { + return migrations.map((migration) => ({ + tag: migration.tag, + ...(migration.new_classes && { new_classes: migration.new_classes }), + ...(migration.renamed_classes && { + renamed_classes: migration.renamed_classes.map((renamedClass) => ({ + from: renamedClass.from, + to: renamedClass.to + })) + }), + ...(migration.deleted_classes && { deleted_classes: migration.deleted_classes }), + ...(migration.new_sqlite_classes && { new_sqlite_classes: migration.new_sqlite_classes }) + })) +} + +export function compileModuleOptions(config: DevflareConfig, result: WranglerConfig): void { + if (config.rules && config.rules.length > 0) { + result.rules = config.rules + } + + if (config.findAdditionalModules !== undefined) { + result.find_additional_modules = config.findAdditionalModules + } + + if (config.baseDir) { + result.base_dir = config.baseDir + } + + if (config.preserveFileNames !== undefined) { + result.preserve_file_names = config.preserveFileNames + } +} + +export function compileContainers(config: DevflareConfig, result: WranglerConfig): void { + if (!config.containers || config.containers.length === 0) { + return + } + + result.containers = config.containers.map((container) => ({ + class_name: container.className, + image: container.image, + ...(container.maxInstances !== undefined && { max_instances: container.maxInstances }), + ...(container.instanceType && { instance_type: container.instanceType }), + ...(container.name && { name: container.name }), + ...(container.imageBuildContext && { image_build_context: container.imageBuildContext }), + ...(container.imageVars && { image_vars: container.imageVars }), + ...(container.rolloutActiveGracePeriod !== undefined && { + rollout_active_grace_period: container.rolloutActiveGracePeriod + }), + ...(container.rolloutStepPercentage !== undefined && { + rollout_step_percentage: container.rolloutStepPercentage + }) + })) +} diff --git a/packages/devflare/src/config/compiler/do-workers.ts b/packages/devflare/src/config/compiler/do-workers.ts new file mode 100644 index 0000000..eb9f2d4 --- /dev/null +++ b/packages/devflare/src/config/compiler/do-workers.ts @@ -0,0 +1,198 @@ +import { resolve } from 'pathe' +import { resolveConfigForEnvironment } from '../resolve' +import { type DevflareConfig, normalizeDOBinding } from '../schema' +import { + getWranglerBrowserBinding, + getWranglerD1DatabaseBinding, + getWranglerKVNamespaceBinding +} from './bindings' +import { compileModuleOptions, compileWranglerMigrations } from './core-helpers' +import type { WranglerConfig } from './types' + +/** + * Derive a deterministic worker name from a Durable Object class name. + * Converts PascalCase/camelCase to kebab-case so it is safe to use as a + * Wrangler worker name. + */ +function kebabCaseClassName(className: string): string { + return className + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^A-Za-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() +} + +/** + * Filter a migration entry down to the subset that applies to the given + * Durable Object class. Returns null when nothing in the migration references + * the class (so that worker does not need that migration tag). + */ +function filterMigrationForClass( + migration: NonNullable[number], + className: string +): NonNullable[number] | null { + const newClasses = migration.new_classes?.filter((name) => name === className) + const newSqliteClasses = migration.new_sqlite_classes?.filter((name) => name === className) + const deletedClasses = migration.deleted_classes?.filter((name) => name === className) + const renamedClasses = migration.renamed_classes?.filter( + (entry) => entry.to === className || entry.from === className + ) + + const hasAny = Boolean( + (newClasses && newClasses.length > 0) || + (newSqliteClasses && newSqliteClasses.length > 0) || + (deletedClasses && deletedClasses.length > 0) || + (renamedClasses && renamedClasses.length > 0) + ) + + if (!hasAny) { + return null + } + + return { + tag: migration.tag, + ...(newClasses && newClasses.length > 0 && { new_classes: newClasses }), + ...(newSqliteClasses && + newSqliteClasses.length > 0 && { new_sqlite_classes: newSqliteClasses }), + ...(deletedClasses && deletedClasses.length > 0 && { deleted_classes: deletedClasses }), + ...(renamedClasses && renamedClasses.length > 0 && { renamed_classes: renamedClasses }) + } +} + +/** + * Compile DO Worker configs from DevflareConfig. + * + * Each distinct Durable Object class is emitted as its own compiled worker + * entry. The worker name is derived from the class being compiled (or from + * an explicit `scriptName` on a binding for that class), never from the + * first binding encountered. + * + * **Public-API only.** This helper is exported for downstream tooling that + * orchestrates multi-worker DO topologies on top of devflare. The internal + * `devflare build` / `devflare deploy` pipeline does **not** call this โ€” + * it emits a single Wrangler config and relies on `wrangler` to handle DO + * placement. C9 in `REMAINING.md` tracks this caveat: nothing inside the + * package exercises this function, so behaviour for current consumers is + * defined by the (small) test surface rather than by the build/deploy + * happy path. Pass `preserveNamedBindings: true` if you want the same + * build-time name preservation that `compileBuildConfig()` uses. + * + * @param config - The devflare configuration + * @param doWorkerEntry - Path to the DO worker entry file (e.g., 'src/workers/do-worker.ts') + * @param options - Additional options + * @param options.absoluteMain - If true, resolve main to absolute path using cwd + * @param options.cwd - Working directory for resolving absolute paths + * @returns Array of Wrangler configs โ€” one per DO class. Empty when no DOs configured. + */ +export function compileDOWorkerConfig( + config: DevflareConfig, + doWorkerEntry: string, + options?: { + absoluteMain?: boolean + cwd?: string + environment?: string + preserveNamedBindings?: boolean + } +): WranglerConfig[] { + const resolvedConfig = resolveConfigForEnvironment(config, options?.environment) + + const doBindings = resolvedConfig.bindings?.durableObjects + if (!doBindings || Object.keys(doBindings).length === 0) { + return [] + } + + // Group bindings by class name. Multiple bindings may point to the same + // class; they are hosted by a single worker dedicated to that class. + const bindingsByClass = new Map< + string, + Array<{ bindingName: string; normalized: ReturnType }> + >() + for (const [bindingName, doConfig] of Object.entries(doBindings)) { + const normalized = normalizeDOBinding(doConfig) + const group = bindingsByClass.get(normalized.className) ?? [] + group.push({ bindingName, normalized }) + bindingsByClass.set(normalized.className, group) + } + + // Resolve main path (absolute if needed for wrangler pages dev) + let mainPath = doWorkerEntry + if (options?.absoluteMain && options.cwd) { + mainPath = resolve(options.cwd, doWorkerEntry) + } + + const results: WranglerConfig[] = [] + + for (const [className, entries] of bindingsByClass) { + const explicitScriptName = entries.find((entry) => entry.normalized.scriptName)?.normalized + .scriptName + const workerName = + explicitScriptName ?? `${resolvedConfig.name}-${kebabCaseClassName(className)}` + + const result: WranglerConfig = { + name: workerName, + main: mainPath, + compatibility_date: resolvedConfig.compatibilityDate + } + + compileModuleOptions(resolvedConfig, result) + + if (resolvedConfig.compatibilityFlags && resolvedConfig.compatibilityFlags.length > 0) { + result.compatibility_flags = resolvedConfig.compatibilityFlags + } + + // DO bindings WITHOUT script_name (the class is defined in this worker) + result.durable_objects = { + bindings: entries.map(({ bindingName, normalized }) => ({ + name: bindingName, + class_name: normalized.className + })) + } + + // Scope migrations to this class only so each worker declares only the + // classes it actually exports. + if (resolvedConfig.migrations && resolvedConfig.migrations.length > 0) { + const classMigrations = resolvedConfig.migrations + .map((migration) => filterMigrationForClass(migration, className)) + .filter((migration): migration is NonNullable => migration !== null) + + if (classMigrations.length > 0) { + result.migrations = compileWranglerMigrations(classMigrations) + } + } + + // Include bindings that DOs might need (storage, browser, etc.) + if (resolvedConfig.bindings?.kv) { + result.kv_namespaces = Object.entries(resolvedConfig.bindings.kv).map( + ([binding, namespace]) => { + return getWranglerKVNamespaceBinding(binding, namespace, options) + } + ) + } + + if (resolvedConfig.bindings?.d1) { + result.d1_databases = Object.entries(resolvedConfig.bindings.d1).map( + ([binding, database_id]) => { + return getWranglerD1DatabaseBinding(binding, database_id, options) + } + ) + } + + if (resolvedConfig.bindings?.r2) { + result.r2_buckets = Object.entries(resolvedConfig.bindings.r2).map( + ([binding, bucket_name]) => ({ + binding, + bucket_name + }) + ) + } + + const browserBinding = getWranglerBrowserBinding(resolvedConfig.bindings?.browser) + if (browserBinding) { + result.browser = browserBinding + } + + results.push(result) + } + + return results +} diff --git a/packages/devflare/src/config/compiler/paths.ts b/packages/devflare/src/config/compiler/paths.ts new file mode 100644 index 0000000..d193c91 --- /dev/null +++ b/packages/devflare/src/config/compiler/paths.ts @@ -0,0 +1,167 @@ +import { basename, isAbsolute, relative, resolve } from 'pathe' +import type { WranglerConfig } from './types' + +/** + * Convert WranglerConfig to JSONC string with comments + */ +export function stringifyConfig(config: WranglerConfig): string { + const header = `// Generated by devflare โ€” Do not edit directly +// Edit devflare.config.ts instead + +` + return header + JSON.stringify(config, null, '\t') +} + +function rebasePathForConfigDir(projectRoot: string, configDir: string, pathValue: string): string { + const absolutePath = isAbsolute(pathValue) ? pathValue : resolve(projectRoot, pathValue) + + return relative(configDir, absolutePath).replace(/\\/g, '/') +} + +function isLocalContainerPath(pathValue: string): boolean { + return ( + pathValue === 'Dockerfile' || + pathValue.startsWith('.') || + pathValue.startsWith('/') || + pathValue.startsWith('\\') || + isAbsolute(pathValue) || + pathValue.endsWith('/Dockerfile') || + pathValue.endsWith('\\Dockerfile') + ) +} + +function pathIsInsideDirectory(directoryPath: string, candidatePath: string): boolean { + const normalizedDirectoryPath = directoryPath.replace(/\\/g, '/') + const normalizedCandidatePath = candidatePath.replace(/\\/g, '/') + + return ( + normalizedCandidatePath === normalizedDirectoryPath || + normalizedCandidatePath.startsWith(`${normalizedDirectoryPath}/`) + ) +} + +export function isolateViteBuildOutputPaths( + projectRoot: string, + config: WranglerConfig +): WranglerConfig { + const assetsDirectory = config.assets?.directory + if (!assetsDirectory) { + return config + } + + const isolatedAssetsDirectoryPath = resolve( + projectRoot, + '.devflare', + 'vite-build-output', + basename(assetsDirectory) + ) + const isolatedAssetsDirectory = relative(projectRoot, isolatedAssetsDirectoryPath).replace( + /\\/g, + '/' + ) + const isolatedConfig: WranglerConfig = { + ...config, + assets: config.assets + ? { + ...config.assets, + directory: isolatedAssetsDirectory + } + : config.assets + } + + if (!config.main) { + return isolatedConfig + } + + const originalAssetsDirectoryPath = resolve(projectRoot, assetsDirectory) + const originalMainEntryPath = resolve(projectRoot, config.main) + if (!pathIsInsideDirectory(originalAssetsDirectoryPath, originalMainEntryPath)) { + return isolatedConfig + } + + const relativeMainEntryPath = relative(originalAssetsDirectoryPath, originalMainEntryPath) + const isolatedMainEntryPath = resolve(isolatedAssetsDirectoryPath, relativeMainEntryPath) + + return { + ...isolatedConfig, + main: relative(projectRoot, isolatedMainEntryPath).replace(/\\/g, '/') + } +} + +export function rebaseWranglerConfigPaths( + projectRoot: string, + configDir: string, + config: WranglerConfig +): WranglerConfig { + return { + ...config, + ...(config.main ? { main: rebasePathForConfigDir(projectRoot, configDir, config.main) } : {}), + ...(config.assets?.directory + ? { + assets: { + ...config.assets, + directory: rebasePathForConfigDir(projectRoot, configDir, config.assets.directory) + } + } + : {}), + ...(config.containers + ? { + containers: config.containers.map((container) => ({ + ...container, + image: isLocalContainerPath(container.image) + ? rebasePathForConfigDir(projectRoot, configDir, container.image) + : container.image, + ...(container.image_build_context && { + image_build_context: rebasePathForConfigDir( + projectRoot, + configDir, + container.image_build_context + ) + }) + })) + } + : {}) + } +} + +/** + * Write wrangler.jsonc file to the specified directory + * + * @param cwd - Working directory to write to + * @param config - Wrangler configuration to write + * @param filename - Optional filename (default: 'wrangler.jsonc') + * @returns Path to the written file + */ +export async function writeWranglerConfig( + cwd: string, + config: WranglerConfig, + filename = 'wrangler.jsonc' +): Promise { + const { resolve } = await import('pathe') + const fs = await import('node:fs/promises') + + // Ensure directory exists + try { + await fs.mkdir(cwd, { recursive: true }) + } catch { + // Directory may already exist + } + + const content = stringifyConfig(config) + const wranglerPath = resolve(cwd, filename) + await fs.writeFile(wranglerPath, content, 'utf-8') + return wranglerPath +} + +export async function readWranglerConfig(filePath: string): Promise { + const fs = await import('node:fs/promises') + const { parse } = await import('jsonc-parser') + const content = await fs.readFile(filePath, 'utf-8') + const parsedConfig = parse(content) + + if (!parsedConfig || typeof parsedConfig !== 'object') { + throw new Error(`Could not parse Wrangler config at ${filePath}.`) + } + + return parsedConfig as WranglerConfig +} diff --git a/packages/devflare/src/config/compiler/types.ts b/packages/devflare/src/config/compiler/types.ts new file mode 100644 index 0000000..a2e12ae --- /dev/null +++ b/packages/devflare/src/config/compiler/types.ts @@ -0,0 +1,250 @@ +/** + * Wrangler config type โ€” represents the output format for wrangler.jsonc + */ +export interface WranglerConfig { + name: string + account_id?: string + main?: string + compatibility_date: string + compatibility_flags?: string[] + rules?: WranglerModuleRule[] + find_additional_modules?: boolean + base_dir?: string + preserve_file_names?: boolean + preview_urls?: boolean + workers_dev?: boolean + + // Bindings + kv_namespaces?: WranglerKVNamespaceBinding[] + d1_databases?: WranglerD1DatabaseBinding[] + r2_buckets?: Array<{ binding: string; bucket_name: string }> + durable_objects?: { + bindings: Array<{ + name: string + class_name: string + script_name?: string + }> + } + queues?: { + producers?: Array<{ binding: string; queue: string }> + consumers?: Array<{ + queue: string + max_batch_size?: number + max_batch_timeout?: number + max_retries?: number + dead_letter_queue?: string + max_concurrency?: number + retry_delay?: number + }> + } + ratelimits?: Array<{ + name: string + namespace_id: string + simple: { + limit: number + period: 10 | 60 + } + }> + version_metadata?: { + binding: string + } + worker_loaders?: Array<{ + binding: string + }> + secrets_store_secrets?: Array<{ + binding: string + store_id: string + secret_name: string + }> + mtls_certificates?: Array<{ + binding: string + certificate_id: string + remote?: boolean + }> + dispatch_namespaces?: Array<{ + binding: string + namespace: string + outbound?: { + service: string + environment?: string + parameters?: string[] + } + remote?: boolean + }> + workflows?: Array<{ + binding: string + name: string + class_name: string + script_name?: string + remote?: boolean + limits?: { + steps: number + } + }> + pipelines?: Array<{ + binding: string + pipeline: string + remote?: boolean + }> + services?: Array<{ + binding: string + service: string + entrypoint?: string + environment?: string + }> + ai?: { binding: string; remote?: boolean; staging?: boolean } + ai_search_namespaces?: Array<{ binding: string; namespace: string; remote?: boolean }> + ai_search?: Array<{ binding: string; instance_name: string; remote?: boolean }> + vectorize?: Array<{ binding: string; index_name: string; remote?: boolean }> + hyperdrive?: WranglerHyperdriveBinding[] + browser?: { binding: string; remote?: boolean } + images?: { + binding: string + remote?: boolean + } + media?: { + binding: string + remote?: boolean + } + artifacts?: Array<{ + binding: string + namespace: string + remote?: boolean + }> + containers?: Array<{ + class_name: string + image: string + max_instances?: number + instance_type?: string + name?: string + image_build_context?: string + image_vars?: Record + rollout_active_grace_period?: number + rollout_step_percentage?: number | number[] + }> + analytics_engine_datasets?: Array<{ binding: string; dataset: string }> + send_email?: Array<{ + name: string + destination_address?: string + allowed_destination_addresses?: string[] + allowed_sender_addresses?: string[] + }> + + // Triggers + triggers?: { + crons?: string[] + } + tail_consumers?: Array<{ + service: string + environment?: string + }> + + // Variables + vars?: Record + secrets?: { + required?: string[] + } + + // Routes + routes?: Array<{ + pattern: string + zone_name?: string + zone_id?: string + custom_domain?: boolean + }> + + // Assets + assets?: { + directory: string + binding?: string + html_handling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none' + not_found_handling?: 'single-page-application' | '404-page' | 'none' + run_worker_first?: boolean | string[] + } + + // Placement + placement?: + | { + mode: 'off' | 'smart' + hint?: string + } + | { + mode?: 'targeted' + region: string + } + | { + mode?: 'targeted' + host: string + } + | { + mode?: 'targeted' + hostname: string + } + + // Observability + observability?: { + enabled?: boolean + head_sampling_rate?: number + logs?: { + enabled?: boolean + head_sampling_rate?: number + invocation_logs?: boolean + persist?: boolean + destinations?: string[] + } + traces?: { + enabled?: boolean + head_sampling_rate?: number + persist?: boolean + destinations?: string[] + } + } + + // Limits + limits?: { + cpu_ms?: number + subrequests?: number + } + + // Migrations + migrations?: Array<{ + tag: string + new_classes?: string[] + renamed_classes?: Array<{ from: string; to: string }> + deleted_classes?: string[] + new_sqlite_classes?: string[] + }> + + // Passthrough fields (any additional fields) + [key: string]: unknown +} + +export type WranglerKVNamespaceBinding = + | { binding: string; id: string } + | { binding: string; name: string } + +export type WranglerD1DatabaseBinding = + | { binding: string; database_id: string } + | { binding: string; database_name: string } + +export type WranglerHyperdriveBinding = + | { binding: string; id: string; localConnectionString?: string } + | { binding: string; name: string; localConnectionString?: string } + +export interface WranglerModuleRule { + type: 'ESModule' | 'CommonJS' | 'CompiledWasm' | 'Text' | 'Data' + globs: string[] + fallthrough?: boolean +} + +export interface CompileConfigOptions { + preserveNamedBindings?: boolean + /** + * If true, skip the internal `resolveConfigForEnvironment` call. Use when + * the caller has already merged environment overrides and materialized + * preview-scoped bindings (e.g. the deploy path). Idempotent today, but + * `R1` step 4 will make env-merge non-idempotent for some array-additive + * fields, so callers on the deploy path should set this explicitly. (CR1.) + */ + alreadyResolved?: boolean +} diff --git a/packages/devflare/src/config/define.ts b/packages/devflare/src/config/define.ts new file mode 100644 index 0000000..5025cb8 --- /dev/null +++ b/packages/devflare/src/config/define.ts @@ -0,0 +1,92 @@ +// ============================================================================= +// defineConfig โ€” Type-safe config definition helper +// ============================================================================= + +import type { DevflareConfigInput } from './schema' +import type { InferConfigVars } from './env-vars' + +/** + * Input type for defineConfig - can be object, function, or async function + * Uses the Zod input type so optional fields with defaults are truly optional + */ +export type DefineConfigInput = + | DevflareConfigInput + | (() => DevflareConfigInput) + | (() => Promise) + +/** + * Configuration with entrypoints type attached for ref() type inference. + * This is used by ref() to provide autocomplete for entrypoint names. + */ +export interface TypedConfig< + TEntrypoints extends string = string, + TVars = Record +> extends DevflareConfigInput { + /** @internal Type marker for entrypoint names - used by ref() for autocomplete */ + readonly __entrypoints?: TEntrypoints + /** @internal Type marker for config-derived runtime vars. */ + readonly __vars?: TVars +} + +/** + * Type-safe helper for defining devflare configuration. + * + * @typeParam TEntrypoints - Union of valid entrypoint names (from generated types) + * + * @example + * // Basic usage (entrypoints default to string) + * export default defineConfig({ + * name: 'my-worker' + * }) + * + * @example + * // With generated entrypoints type (after `devflare types`) + * // env.d.ts exports: type Entrypoints = 'AdminEntrypoint' | 'OtherEntrypoint' + * export default defineConfig({ + * name: 'my-worker', + * files: { fetch: 'worker.ts' } + * }) + * + * @example + * // Function config + * export default defineConfig(() => ({ + * name: process.env.WORKER_NAME ?? 'my-worker', + * compatibilityDate: '2025-01-07' + * })) + */ +export function defineConfig< + TEntrypoints extends string = string, + TConfig extends DevflareConfigInput = DevflareConfigInput +>( + config: TConfig & DevflareConfigInput +): TypedConfig>> +export function defineConfig< + TEntrypoints extends string = string, + TConfig extends DevflareConfigInput = DevflareConfigInput +>( + config: () => TConfig & DevflareConfigInput +): TypedConfig>> +export function defineConfig< + TEntrypoints extends string = string, + TConfig extends DevflareConfigInput = DevflareConfigInput +>( + config: () => Promise +): Promise>>> +export function defineConfig< + TEntrypoints extends string = string, + TConfig extends DevflareConfigInput = DevflareConfigInput +>( + config: + | (TConfig & DevflareConfigInput) + | (() => TConfig & DevflareConfigInput) + | (() => Promise) +): TypedConfig>> | Promise>>> { + if (typeof config === 'function') { + const result = config() + if (result instanceof Promise) { + return result as Promise>>> + } + return result as TypedConfig>> + } + return config as TypedConfig>> +} diff --git a/packages/devflare/src/config/deploy-resources.ts b/packages/devflare/src/config/deploy-resources.ts new file mode 100644 index 0000000..3076be3 --- /dev/null +++ b/packages/devflare/src/config/deploy-resources.ts @@ -0,0 +1,596 @@ +import { + createD1Database, + createKVNamespace, + createQueue, + createR2Bucket, + getPrimaryAccount, + listD1Databases, + listHyperdrives, + listKVNamespaces, + listQueues, + listR2Buckets, + listVectorizeIndexes, + type D1DatabaseInfo, + type HyperdriveConfigInfo, + type KVNamespaceInfo, + type QueueInfo, + type R2BucketInfo, + type VectorizeIndexInfo +} from '../cloudflare/account' +import { getEffectiveAccountId } from '../cloudflare/preferences' +import { + collectPendingNameBindings, + formatMissingBindings, + materializeHyperdriveIdBindings, + materializeIdBindings, + materializeResolvedNameBindings, + normalizeD1NameBinding, + normalizeHyperdriveNameBinding, + normalizeKVNameBinding, + withResolvedIdBindings, + type PendingNameBinding +} from './binding-resolution-helpers' +import { type PreviewResolutionOptions } from './preview' +import { + getLocalD1DatabaseIdentifier, + getLocalKVNamespaceIdentifier, + type DevflareConfig +} from './schema' +import { ConfigResourceResolutionError } from './resource-resolution' +import { brandAsDeployConfig, resolveResources, type DeployConfig } from './resolve-phased' + +interface DeployResourcePreparationApi { + getPrimaryAccount: typeof getPrimaryAccount + getEffectiveAccountId: typeof getEffectiveAccountId + listKVNamespaces: typeof listKVNamespaces + createKVNamespace: typeof createKVNamespace + listD1Databases: typeof listD1Databases + createD1Database: typeof createD1Database + listR2Buckets: typeof listR2Buckets + createR2Bucket: typeof createR2Bucket + listQueues: typeof listQueues + createQueue: typeof createQueue + listHyperdrives: typeof listHyperdrives + listVectorizeIndexes: typeof listVectorizeIndexes +} + +const defaultDeployResourcePreparationApi: DeployResourcePreparationApi = { + getPrimaryAccount, + getEffectiveAccountId, + listKVNamespaces, + createKVNamespace, + listD1Databases, + createD1Database, + listR2Buckets, + createR2Bucket, + listQueues, + createQueue, + listHyperdrives, + listVectorizeIndexes +} + +export interface DeployResourceNames { + kv: string[] + d1: string[] + r2: string[] + queues: string[] + vectorize: string[] + hyperdrive: string[] +} + +export interface PrepareConfigResourcesForDeployOptions { + environment?: string + env?: PreviewResolutionOptions['env'] + identifier?: string + accountId?: string + cloudflare?: Partial + /** C6 โ€” see `PrepareMaterializedConfigResourcesForDeployOptions.describeOnly`. */ + describeOnly?: boolean +} + +export interface PrepareMaterializedConfigResourcesForDeployOptions { + accountId?: string + cloudflare?: Partial + /** + * C6 โ€” describe-only mode. When true, no `create*` Cloudflare APIs are + * called. Resources missing in the account are reported via the returned + * `created` field (their IDs are placeholder strings prefixed with + * ``), so dry-run can render the same plan that a real + * deploy would execute without performing any side effects. + */ + describeOnly?: boolean +} + +export interface PrepareConfigResourcesForDeployResult { + config: DeployConfig + created: DeployResourceNames + existing: DeployResourceNames + warnings: string[] +} + +interface ResolvedResourceIdsByNameResult { + idsByName: Map + created: string[] + existing: string[] +} + +function createEmptyDeployResourceNames(): DeployResourceNames { + return { + kv: [], + d1: [], + r2: [], + queues: [], + vectorize: [], + hyperdrive: [] + } +} + +/** + * C13 โ€” surface partial-progress orphans on a failed deploy preparation. + * + * If any resource was already created before the throw, attach a clear + * footer to the error message listing what was created so the user can + * decide whether to keep, delete, or rerun. Auto-deletion is intentionally + * not performed: deletion is irreversible, has cross-binding side effects + * (DBs may have data, queues may be inflight), and the deploy may simply + * be retried after fixing the underlying error. + */ +function decorateOrphanError(err: unknown, created: DeployResourceNames): Error { + const summaryParts: string[] = [] + if (created.kv.length > 0) summaryParts.push(`KV: ${created.kv.join(', ')}`) + if (created.d1.length > 0) summaryParts.push(`D1: ${created.d1.join(', ')}`) + if (created.hyperdrive.length > 0) summaryParts.push(`Hyperdrive: ${created.hyperdrive.join(', ')}`) + if (created.r2.length > 0) summaryParts.push(`R2: ${created.r2.join(', ')}`) + if (created.queues.length > 0) summaryParts.push(`Queues: ${created.queues.join(', ')}`) + if (created.vectorize.length > 0) summaryParts.push(`Vectorize: ${created.vectorize.join(', ')}`) + + const base = err instanceof Error ? err : new Error(String(err)) + if (summaryParts.length === 0) return base + + const orphanFooter = + `\n\nDeploy preparation failed AFTER provisioning the following Cloudflare resources, ` + + `which were left in your account:\n - ${summaryParts.join('\n - ')}\n` + + `Re-run \`devflare deploy\` after fixing the error to reuse them, or delete them manually if abandoning the deploy.` + + const decorated = new Error(`${base.message}${orphanFooter}`) + if ('cause' in base && base.cause !== undefined) { + ;(decorated as Error & { cause: unknown }).cause = base.cause + } else { + ;(decorated as Error & { cause: unknown }).cause = base + } + return decorated +} + +function resolveDeployResourcePreparationApi( + overrides: Partial | undefined +): DeployResourcePreparationApi { + return { + ...defaultDeployResourcePreparationApi, + ...(overrides ?? {}) + } +} + +function resolveUniqueNames(values: Iterable): string[] { + const names = new Set() + + for (const value of values) { + const trimmed = value?.trim() + if (!trimmed) { + continue + } + + names.add(trimmed) + } + + return [...names] +} + +function collectQueueNames(config: DevflareConfig): string[] { + const queues = config.bindings?.queues + if (!queues) { + return [] + } + + return resolveUniqueNames([ + ...Object.values(queues.producers ?? {}), + ...(queues.consumers ?? []).flatMap((consumer) => [consumer.queue, consumer.deadLetterQueue]) + ]) +} + +function collectVectorizeIndexNames(config: DevflareConfig): string[] { + return resolveUniqueNames( + Object.values(config.bindings?.vectorize ?? {}).map((binding) => binding.indexName) + ) +} + +function resolveUniquePendingBindings(pendingBindings: PendingNameBinding[]): PendingNameBinding[] { + return [...new Map( + pendingBindings.map((binding) => [binding.resourceName, binding]) + ).values()] +} + +async function resolveLookupAccountId( + config: DevflareConfig, + options: PrepareMaterializedConfigResourcesForDeployOptions, + cloudflareApi: DeployResourcePreparationApi +): Promise { + // Priority order matches command-utils.resolveCloudflareAccountId so the + // account that provisions resources is always the same one the worker is + // deployed against. Without the env-var fallback here, a CI job could + // auto-create KV/D1 namespaces in the personal "primary" account while + // `wrangler deploy` simultaneously targets the env-var account โ€” leaving + // orphaned resources cross-account with no warning. + const envAccountId = typeof process !== 'undefined' + ? process.env?.CLOUDFLARE_ACCOUNT_ID?.trim() + : undefined + const explicitAccountId = options.accountId ?? config.accountId ?? (envAccountId || undefined) + if (explicitAccountId) { + return explicitAccountId + } + + let primaryAccount + try { + primaryAccount = await cloudflareApi.getPrimaryAccount() + } catch (error) { + throw new ConfigResourceResolutionError( + 'Could not prepare Cloudflare-backed deploy resources because Devflare could not read your Cloudflare accounts. Set accountId in devflare.config.ts, configure a workspace/global default account, or log in with Wrangler.', + error + ) + } + + if (!primaryAccount) { + throw new ConfigResourceResolutionError( + 'Could not prepare Cloudflare-backed deploy resources because no Cloudflare account is available. Set accountId in devflare.config.ts, configure a workspace/global default account, or log in with Wrangler.' + ) + } + + try { + const { accountId } = await cloudflareApi.getEffectiveAccountId(primaryAccount.id) + return accountId + } catch (error) { + throw new ConfigResourceResolutionError( + `Could not determine the effective Cloudflare account for deploy-time resource preparation after selecting primary account ${primaryAccount.id}.`, + error + ) + } +} + +async function resolveOrCreateResourceIdsByName( + pendingBindings: PendingNameBinding[], + options: { + listResources: () => Promise + createResource?: (resourceName: string) => Promise + listFailureMessage: string + missingFailureMessage: (missing: PendingNameBinding[]) => string + createFailureMessage?: (resourceName: string) => string + } +): Promise { + if (pendingBindings.length === 0) { + return { + idsByName: new Map(), + created: [], + existing: [] + } + } + + let resources: TResource[] + try { + resources = await options.listResources() + } catch (error) { + throw new ConfigResourceResolutionError(options.listFailureMessage, error) + } + + const idsByName = new Map( + resources.map((resource) => [resource.name, resource.id]) + ) + const created: string[] = [] + const existing = resolveUniquePendingBindings(pendingBindings) + .filter(({ resourceName }) => idsByName.has(resourceName)) + .map(({ resourceName }) => resourceName) + const missingBindings = resolveUniquePendingBindings(pendingBindings) + .filter(({ resourceName }) => !idsByName.has(resourceName)) + + if (missingBindings.length === 0) { + return { + idsByName, + created, + existing + } + } + + if (!options.createResource) { + throw new ConfigResourceResolutionError(options.missingFailureMessage(missingBindings)) + } + + for (const missingBinding of missingBindings) { + try { + const createdResource = await options.createResource(missingBinding.resourceName) + idsByName.set(createdResource.name, createdResource.id) + created.push(createdResource.name) + } catch (error) { + throw new ConfigResourceResolutionError( + options.createFailureMessage?.(missingBinding.resourceName) + ?? `Could not create Cloudflare resource "${missingBinding.resourceName}" during deploy preparation.`, + error + ) + } + } + + return { + idsByName, + created, + existing + } +} + +async function ensureNamedResourcesExist( + resourceNames: string[], + options: { + listResources: () => Promise + createResource?: (resourceName: string) => Promise + listFailureMessage: string + missingFailureMessage: (missingNames: string[]) => string + createFailureMessage?: (resourceName: string) => string + } +): Promise<{ + created: string[] + existing: string[] +}> { + if (resourceNames.length === 0) { + return { + created: [], + existing: [] + } + } + + let resources: TResource[] + try { + resources = await options.listResources() + } catch (error) { + throw new ConfigResourceResolutionError(options.listFailureMessage, error) + } + + const existingNames = new Set(resources.map((resource) => resource.name)) + const existing = resourceNames.filter((resourceName) => existingNames.has(resourceName)) + const missingNames = resourceNames.filter((resourceName) => !existingNames.has(resourceName)) + const created: string[] = [] + + if (missingNames.length === 0) { + return { + created, + existing + } + } + + if (!options.createResource) { + throw new ConfigResourceResolutionError(options.missingFailureMessage(missingNames)) + } + + for (const resourceName of missingNames) { + try { + const createdResource = await options.createResource(resourceName) + created.push(createdResource.name) + } catch (error) { + throw new ConfigResourceResolutionError( + options.createFailureMessage?.(resourceName) + ?? `Could not create Cloudflare resource "${resourceName}" during deploy preparation.`, + error + ) + } + } + + return { + created, + existing + } +} + +export async function prepareMaterializedConfigResourcesForDeploy( + resolvedConfig: DevflareConfig, + options: PrepareMaterializedConfigResourcesForDeployOptions = {} +): Promise { + const created = createEmptyDeployResourceNames() + const existing = createEmptyDeployResourceNames() + const warnings: string[] = [] + const kvBindings = resolvedConfig.bindings?.kv + const d1Bindings = resolvedConfig.bindings?.d1 + const hyperdriveBindings = resolvedConfig.bindings?.hyperdrive + const r2Names = resolveUniqueNames(Object.values(resolvedConfig.bindings?.r2 ?? {})) + const queueNames = collectQueueNames(resolvedConfig) + const vectorizeNames = collectVectorizeIndexNames(resolvedConfig) + + if (!kvBindings && !d1Bindings && !hyperdriveBindings && r2Names.length === 0 && queueNames.length === 0 && vectorizeNames.length === 0) { + return { + config: brandAsDeployConfig(resolvedConfig), + created, + existing, + warnings + } + } + + const pendingKVNameBindings = collectPendingNameBindings(kvBindings, normalizeKVNameBinding) + const pendingD1NameBindings = collectPendingNameBindings(d1Bindings, normalizeD1NameBinding) + const pendingHyperdriveNameBindings = collectPendingNameBindings(hyperdriveBindings, normalizeHyperdriveNameBinding) + + if ( + pendingKVNameBindings.length === 0 + && pendingD1NameBindings.length === 0 + && pendingHyperdriveNameBindings.length === 0 + && r2Names.length === 0 + && queueNames.length === 0 + && vectorizeNames.length === 0 + ) { + return { + config: brandAsDeployConfig(withResolvedIdBindings(resolvedConfig, { + kv: kvBindings ? materializeIdBindings(kvBindings, getLocalKVNamespaceIdentifier) : undefined, + d1: d1Bindings ? materializeIdBindings(d1Bindings, getLocalD1DatabaseIdentifier) : undefined, + hyperdrive: materializeHyperdriveIdBindings(hyperdriveBindings) + })), + created, + existing, + warnings + } + } + + const cloudflareApi = resolveDeployResourcePreparationApi(options.cloudflare) + const accountId = await resolveLookupAccountId(resolvedConfig, options, cloudflareApi) + + // C6 โ€” describe-only mode for dry-run: stub the create-* APIs so they + // return `` placeholders rather than calling the live + // Cloudflare API. Resolution of existing resources still happens for + // real, so the dry-run output reflects the actual mix of "would create" + // vs "would reuse" the live deploy would perform. + if (options.describeOnly) { + cloudflareApi.createKVNamespace = (async (_acc: string, name: string) => + ({ id: ``, name })) as DeployResourcePreparationApi['createKVNamespace'] + cloudflareApi.createD1Database = (async (_acc: string, name: string) => + ({ id: ``, name, version: '', tableCount: 0, sizeBytes: 0 })) as DeployResourcePreparationApi['createD1Database'] + cloudflareApi.createR2Bucket = (async (_acc: string, name: string) => + ({ name })) as DeployResourcePreparationApi['createR2Bucket'] + cloudflareApi.createQueue = (async (_acc: string, name: string) => + ({ id: ``, name })) as DeployResourcePreparationApi['createQueue'] + } + + // C13 โ€” sequential provisioning leaves silent orphans. We do not + // auto-delete created resources on a later failure (Cloudflare resource + // deletion is asynchronous, partial, and risky against resources a user + // might already be reading). Instead we make the orphans LOUD: any + // throw between KV/D1/Hyperdrive/R2/Queues/Vectorize is re-thrown + // decorated with the exact set of resources already created during this + // deploy, so the user can clean them up manually if the deploy is + // abandoned. + try { + // C3 โ€” resolve-only resources (Hyperdrive, Vectorize) FIRST so that a + // "create the index first" failure happens BEFORE we provision any + // KV/D1/R2/Queue resources. Otherwise a missing Hyperdrive config would + // only be detected after side effects (orphans). + const hyperdriveIdsByName = await resolveOrCreateResourceIdsByName(pendingHyperdriveNameBindings, { + listResources: async () => cloudflareApi.listHyperdrives(accountId), + listFailureMessage: `Could not list Hyperdrive configurations for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingBindings) => { + return `Could not find Hyperdrive configuration(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}. Cloudflare does not expose a create API that Devflare can use from only a binding name, so create the Hyperdrive config first or configure the binding with an explicit id.` + } + }) + created.hyperdrive.push(...hyperdriveIdsByName.created) + existing.hyperdrive.push(...hyperdriveIdsByName.existing) + + const vectorizeState = await ensureNamedResourcesExist(vectorizeNames, { + listResources: async () => cloudflareApi.listVectorizeIndexes(accountId), + listFailureMessage: `Could not list Vectorize indexes for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingNames) => { + return `Could not find Vectorize index(es) ${missingNames.join(', ')} in Cloudflare account ${accountId}. Devflare can only auto-provision preview-scoped Vectorize indexes by cloning an existing base index; for normal deploys create the index first.` + } + }) + created.vectorize.push(...vectorizeState.created) + existing.vectorize.push(...vectorizeState.existing) + + const namespaceIdsByName = await resolveOrCreateResourceIdsByName(pendingKVNameBindings, { + listResources: async () => cloudflareApi.listKVNamespaces(accountId), + createResource: async (resourceName) => cloudflareApi.createKVNamespace(accountId, resourceName), + listFailureMessage: `Could not list KV namespaces for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingBindings) => { + return `Could not find KV namespace(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}.` + }, + createFailureMessage: (resourceName) => { + return `Could not create KV namespace "${resourceName}" in Cloudflare account ${accountId} during deploy preparation.` + } + }) + created.kv.push(...namespaceIdsByName.created) + existing.kv.push(...namespaceIdsByName.existing) + + const databaseIdsByName = await resolveOrCreateResourceIdsByName(pendingD1NameBindings, { + listResources: async () => cloudflareApi.listD1Databases(accountId), + createResource: async (resourceName) => cloudflareApi.createD1Database(accountId, resourceName), + listFailureMessage: `Could not list D1 databases for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingBindings) => { + return `Could not find D1 database(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}.` + }, + createFailureMessage: (resourceName) => { + return `Could not create D1 database "${resourceName}" in Cloudflare account ${accountId} during deploy preparation.` + } + }) + created.d1.push(...databaseIdsByName.created) + existing.d1.push(...databaseIdsByName.existing) + + const r2State = await ensureNamedResourcesExist(r2Names, { + listResources: async () => cloudflareApi.listR2Buckets(accountId), + createResource: async (resourceName) => cloudflareApi.createR2Bucket(accountId, resourceName), + listFailureMessage: `Could not list R2 buckets for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingNames) => { + return `Could not find R2 bucket(s) ${missingNames.join(', ')} in Cloudflare account ${accountId}.` + }, + createFailureMessage: (resourceName) => { + return `Could not create R2 bucket "${resourceName}" in Cloudflare account ${accountId} during deploy preparation.` + } + }) + created.r2.push(...r2State.created) + existing.r2.push(...r2State.existing) + + const queueState = await ensureNamedResourcesExist(queueNames, { + listResources: async () => cloudflareApi.listQueues(accountId), + createResource: async (resourceName) => cloudflareApi.createQueue(accountId, resourceName), + listFailureMessage: `Could not list Queues for Cloudflare account ${accountId} while preparing deploy resources.`, + missingFailureMessage: (missingNames) => { + return `Could not find Queue(s) ${missingNames.join(', ')} in Cloudflare account ${accountId}.` + }, + createFailureMessage: (resourceName) => { + return `Could not create Queue "${resourceName}" in Cloudflare account ${accountId} during deploy preparation.` + } + }) + created.queues.push(...queueState.created) + existing.queues.push(...queueState.existing) + + const config = withResolvedIdBindings(resolvedConfig, { + kv: kvBindings + ? pendingKVNameBindings.length > 0 + ? materializeResolvedNameBindings(kvBindings, normalizeKVNameBinding, namespaceIdsByName.idsByName) + : materializeIdBindings(kvBindings, getLocalKVNamespaceIdentifier) + : undefined, + d1: d1Bindings + ? pendingD1NameBindings.length > 0 + ? materializeResolvedNameBindings(d1Bindings, normalizeD1NameBinding, databaseIdsByName.idsByName) + : materializeIdBindings(d1Bindings, getLocalD1DatabaseIdentifier) + : undefined, + hyperdrive: hyperdriveBindings + ? pendingHyperdriveNameBindings.length > 0 + ? materializeHyperdriveIdBindings(hyperdriveBindings, hyperdriveIdsByName.idsByName) + : materializeHyperdriveIdBindings(hyperdriveBindings) + : undefined + }) + + return { + config: brandAsDeployConfig(config), + created, + existing, + warnings + } + } catch (err) { + throw decorateOrphanError(err, created) + } +} + +export async function prepareConfigResourcesForDeploy( + config: DevflareConfig, + options: PrepareConfigResourcesForDeployOptions = {} +): Promise { + // C2 step 3 โ€” env-merge + preview-materialization is the build-phase work + // of the unified resolveResources seam. We then hand the prepared config to + // the materialised-deploy helper so we still get the richer + // `{ created, existing, warnings }` result that the seam itself does not + // expose for the provisioning case. + const resolvedConfig = await resolveResources(config, { + phase: 'build', + environment: options.environment, + preview: { + environment: options.environment, + env: options.env, + identifier: options.identifier + } + }) + + return prepareMaterializedConfigResourcesForDeploy(resolvedConfig, { + accountId: options.accountId, + cloudflare: options.cloudflare, + describeOnly: options.describeOnly + }) +} diff --git a/packages/devflare/src/config/env-vars.ts b/packages/devflare/src/config/env-vars.ts new file mode 100644 index 0000000..edd68e8 --- /dev/null +++ b/packages/devflare/src/config/env-vars.ts @@ -0,0 +1,690 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'pathe' +import type { DevflareConfig } from './schema' + +const ENV_DESCRIPTOR_FLAG = '__devflareEnvDescriptor' + +/** + * Environment-variable resolution mode. + * + * `build` treats missing required variables as fatal. `dev` also reports + * missing required variables, but dev-server startup can wait for `.env` + * changes and retry. + * + * @default 'build' + */ +export type EnvResolutionMode = 'build' | 'dev' + +type EnvParser = (value: string) => T + +export interface EnvVarDescriptorState { + name: string + optional: TOptional + parser?: EnvParser + defaultValue?: TValue + hasDefault: boolean + devValue?: TValue + hasDevDefault: boolean +} + +/** + * A typed environment-variable descriptor used inside `defineConfig({ vars })`. + * + * Descriptors are resolved by Devflare before a dev runtime or build artifact is + * started. The descriptor itself is intentionally inert while the config module + * is imported, so missing variables can be reported with config paths instead + * of throwing during module evaluation. + * + * @typeParam TValue - Runtime value produced by the optional parser. + * @typeParam TOptional - Whether a missing value is allowed. + */ +export interface EnvVarDescriptor { + readonly [ENV_DESCRIPTOR_FLAG]: true + readonly __state: EnvVarDescriptorState + + /** + * Allow this variable to be missing. Missing optional variables are omitted + * from generated Worker vars. + * + * @example + * ```ts + * vars: { + * MAYBE_LABEL: env.MAYBE_LABEL.optional() + * } + * ``` + */ + optional(): EnvVarDescriptor + + /** + * Parse the string value read from `.env` / process env into a runtime value. + * + * @example + * ```ts + * vars: { + * RETRIES: env.RETRIES.parse(Number) + * } + * ``` + */ + parse(parser: EnvParser): EnvVarDescriptor + + /** + * Alias for {@link parse}. + * + * @example + * ```ts + * vars: { + * PORT: env.PORT.parser(parseInt) + * } + * ``` + */ + parser(parser: EnvParser): EnvVarDescriptor + + /** + * Use this value when the environment variable is missing in any mode. + * + * @example + * ```ts + * vars: { + * APP_ENV: env.APP_ENV.default('local') + * } + * ``` + */ + default(value: TDefault): EnvVarDescriptor + + /** + * Use this value only when the environment variable is missing in dev mode. + * Build mode still treats the variable as required unless `.default()` or + * `.optional()` is also chained. + * + * @example + * ```ts + * vars: { + * MOCK_TENANT_ID: env.MOCK_TENANT_ID.dev(123) + * } + * ``` + */ + dev(value: TDev): EnvVarDescriptor +} + +/** + * One value allowed under `defineConfig({ vars })`. + * + * Use literals for values that are already known, or `env.NAME` descriptors + * for values loaded from `.env`, `.env.dev`, or `process.env`. + * + * @example + * ```ts + * vars: { + * serviceName: 'api', + * mongo: { + * uri: env.MONGOURI, + * poolSize: env.MONGO_POOL_SIZE.parse(Number) + * } + * } + * ``` + */ +export type DevflareVarInput = + | string + | number + | boolean + | null + | EnvVarDescriptor + | { [key: string]: DevflareVarInput } + | DevflareVarInput[] + +/** + * Runtime variable map accepted by `defineConfig({ vars })`. + * + * @default {} + */ +export type DevflareVarsInput = Record + +export type InferEnvVarDescriptor = + T extends EnvVarDescriptor + ? TOptional extends true + ? TValue | undefined + : TValue + : never + +type InferOptionalKeys> = { + [K in keyof T]-?: undefined extends InferConfigVars ? K : never +}[keyof T] + +type InferRequiredKeys> = Exclude> + +/** + * Infer the runtime `vars` shape from the authored config value. + * + * This is used by generated `env.d.ts` files so `import { vars } from + * 'devflare'` can expose nested variables and parser return values without + * hand-maintained duplicate types. + * + * @example + * ```ts + * type Vars = InferConfigVars<{ + * mongo: { + * database: typeof env.MONGODATABASE + * }, + * retries: ReturnType> + * }> + * ``` + */ +export type InferConfigVars = + T extends { readonly __vars?: infer TVars } + ? TVars + : T extends EnvVarDescriptor + ? InferEnvVarDescriptor + : T extends readonly (infer TItem)[] + ? InferConfigVars[] + : T extends Record + ? { + [K in InferRequiredKeys]: Exclude, undefined> + } & { + [K in InferOptionalKeys]?: Exclude, undefined> + } + : T + +function createDescriptor( + state: EnvVarDescriptorState +): EnvVarDescriptor { + const descriptor = { + [ENV_DESCRIPTOR_FLAG]: true as const, + __state: state, + optional() { + return createDescriptor({ + ...state, + optional: true + }) + }, + parse(parser: EnvParser) { + return createDescriptor({ + ...state, + parser + } as unknown as EnvVarDescriptorState) + }, + parser(parser: EnvParser) { + return this.parse(parser) + }, + default(value: TDefault) { + return createDescriptor({ + ...state, + defaultValue: value, + hasDefault: true, + optional: false + } as unknown as EnvVarDescriptorState) + }, + dev(value: TDev) { + return createDescriptor({ + ...state, + devValue: value, + hasDevDefault: true + } as unknown as EnvVarDescriptorState) + } + } + + return descriptor as EnvVarDescriptor +} + +function createEnvVarDescriptor(name: string): EnvVarDescriptor { + return createDescriptor({ + name, + optional: false, + hasDefault: false, + hasDevDefault: false + }) +} + +/** + * Config-time environment variable descriptor factory. + * + * Accessing a property creates a descriptor for the exact environment variable + * name, so `env.SECRET` reads `SECRET=...` from Devflare-loaded `.env` files or + * from `process.env`. + * + * @example + * ```ts + * import { defineConfig, env } from 'devflare/config' + * + * export default defineConfig({ + * vars: { + * secret: env.SECRET, + * mongo: { + * uri: env.MONGOURI, + * database: env.MONGODATABASE + * }, + * retries: env.RETRIES.parse(Number) + * } + * }) + * ``` + */ +export const env: Record> = new Proxy( + {} as Record>, + { + get(_target, prop: string | symbol) { + if (typeof prop !== 'string') { + return undefined + } + + return createEnvVarDescriptor(prop) + } + } +) + +/** + * Return whether a value was created by the config-time {@link env} proxy. + * + * @example + * ```ts + * isEnvVarDescriptor(env.SECRET) // true + * ``` + */ +export function isEnvVarDescriptor(value: unknown): value is EnvVarDescriptor { + return Boolean( + value + && typeof value === 'object' + && (value as { [ENV_DESCRIPTOR_FLAG]?: unknown })[ENV_DESCRIPTOR_FLAG] === true + ) +} + +function parseEnvValue(rawValue: string): string { + const trimmed = rawValue.trim() + const quote = trimmed[0] + + if ( + (quote === '"' || quote === "'" || quote === '`') + && trimmed.endsWith(quote) + && trimmed.length >= 2 + ) { + const inner = trimmed.slice(1, -1) + if (quote !== '"') { + return inner + } + + return inner + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + } + + return trimmed +} + +/** + * Parse `.env` file contents with Devflare's no-expansion rules. + * + * Devflare intentionally does not expand `$OTHER_VARIABLE` references, so + * values are read as written instead of going through Bun's environment-file + * parser. + * + * @example + * ```ts + * parseDevflareEnvFile('TOKEN=abc$123') + * // { TOKEN: 'abc$123' } + * ``` + */ +export function parseDevflareEnvFile(contents: string): Record { + const values: Record = {} + + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const assignment = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trimStart() + : trimmed + const equalsIndex = assignment.indexOf('=') + if (equalsIndex <= 0) { + continue + } + + const key = assignment.slice(0, equalsIndex).trim() + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + continue + } + + values[key] = parseEnvValue(assignment.slice(equalsIndex + 1)) + } + + return values +} + +function collectAncestorDirectories(startDir: string): string[] { + const directories: string[] = [] + let current = resolve(startDir) + + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + break + } + current = parent + } + + return directories.reverse() +} + +/** + * Return `.env.dev` and `.env` candidate paths from the filesystem root to a + * project directory. + * + * Paths are ordered in the same precedence order as loading: parent files + * first, then closer files, with `.env` after `.env.dev` for each directory. + * + * @example + * ```ts + * getDevflareDotenvPaths(process.cwd()) + * ``` + */ +export function getDevflareDotenvPaths(startDir: string): string[] { + return collectAncestorDirectories(startDir).flatMap((directory) => [ + resolve(directory, '.env.dev'), + resolve(directory, '.env') + ]) +} + +export interface LoadDevflareDotenvResult { + /** + * Merged values from all discovered `.env.dev` and `.env` files. + * + * @default {} + */ + values: Record + + /** + * Absolute file paths that contributed values, ordered by load precedence. + * + * @default [] + */ + files: string[] +} + +/** + * Load Devflare `.env.dev` and `.env` files without mutating `process.env`. + * + * Parent directories are loaded first, closer directories override them, and + * `.env` overrides `.env.dev` within the same directory. + * + * @example + * ```ts + * const { values } = await loadDevflareDotenv(process.cwd()) + * ``` + */ +export async function loadDevflareDotenv(startDir: string): Promise { + const values: Record = {} + const files: string[] = [] + + for (const filePath of getDevflareDotenvPaths(startDir)) { + if (!existsSync(filePath)) { + continue + } + + Object.assign(values, parseDevflareEnvFile(await readFile(filePath, 'utf8'))) + files.push(filePath) + } + + return { values, files } +} + +/** + * Load Devflare `.env` values into `process.env` without overwriting existing + * process-level values. + * + * @example + * ```ts + * await loadDevflareDotenvIntoProcess(process.cwd()) + * ``` + */ +export async function loadDevflareDotenvIntoProcess(startDir: string): Promise { + const loaded = await loadDevflareDotenv(startDir) + + for (const [key, value] of Object.entries(loaded.values)) { + if (process.env[key] === undefined) { + process.env[key] = value + } + } + + return loaded +} + +export interface MissingEnvVar { + /** + * Nested `vars` path that required the missing variable. + * + * @example ['mongo', 'uri'] + */ + path: string[] + + /** + * Exact environment variable name that was missing. + * + * @example 'MONGOURI' + */ + name: string +} + +function formatMissingEnvTree(missing: MissingEnvVar[]): string { + const root: Record = {} + + for (const item of missing) { + let current = root + for (const segment of item.path.slice(0, -1)) { + const next = current[segment] + if (!next || typeof next !== 'object') { + current[segment] = {} + } + current = current[segment] as Record + } + + current[item.path[item.path.length - 1] ?? item.name] = item.name + } + + const lines: string[] = [] + const writeNode = (node: Record, depth: number) => { + const indent = '\t'.repeat(depth) + for (const [key, value] of Object.entries(node)) { + if (value && typeof value === 'object') { + lines.push(`${indent}${key}:`) + writeNode(value as Record, depth + 1) + } else { + lines.push(`${indent}${key}: ${String(value)}`) + } + } + } + + writeNode(root, 1) + return lines.join('\n') +} + +export class EnvVarResolutionError extends Error { + readonly code = 'ENV_VARS_MISSING' + + constructor( + public readonly missing: MissingEnvVar[], + public readonly mode: EnvResolutionMode + ) { + super([ + 'These environment variables are missing:', + '', + formatMissingEnvTree(missing) + ].join('\n')) + this.name = 'EnvVarResolutionError' + } +} + +export class EnvVarParseError extends Error { + readonly code = 'ENV_VAR_PARSE_FAILED' + + constructor( + public readonly variableName: string, + public readonly path: string[], + cause: unknown + ) { + super( + `Could not parse environment variable ${variableName} for vars.${path.join('.')}.\n` + + `Parser error: ${cause instanceof Error ? cause.message : String(cause)}` + ) + this.name = 'EnvVarParseError' + } +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value) || isEnvVarDescriptor(value)) { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +const OMIT_VALUE = Symbol('omit optional env var') + +type ResolveValueResult = unknown | typeof OMIT_VALUE + +function resolveDescriptorValue( + descriptor: EnvVarDescriptor, + sources: Record, + path: string[], + mode: EnvResolutionMode, + missing: MissingEnvVar[] +): ResolveValueResult { + const state = descriptor.__state + const rawValue = sources[state.name] + + if (rawValue !== undefined) { + try { + return state.parser ? state.parser(rawValue) : rawValue + } catch (error) { + throw new EnvVarParseError(state.name, path, error) + } + } + + if (mode === 'dev' && state.hasDevDefault) { + return state.devValue + } + + if (state.hasDefault) { + return state.defaultValue + } + + if (state.optional) { + return OMIT_VALUE + } + + missing.push({ path, name: state.name }) + return OMIT_VALUE +} + +function resolveVarValue( + value: unknown, + sources: Record, + path: string[], + mode: EnvResolutionMode, + missing: MissingEnvVar[] +): ResolveValueResult { + if (isEnvVarDescriptor(value)) { + return resolveDescriptorValue(value, sources, path, mode, missing) + } + + if (Array.isArray(value)) { + return value + .map((item, index) => resolveVarValue(item, sources, [...path, String(index)], mode, missing)) + .filter((item) => item !== OMIT_VALUE) + } + + if (isPlainObject(value)) { + const resolved: Record = {} + for (const [key, childValue] of Object.entries(value)) { + const child = resolveVarValue(childValue, sources, [...path, key], mode, missing) + if (child !== OMIT_VALUE) { + resolved[key] = child + } + } + return resolved + } + + return value +} + +function resolveVarsObject( + vars: DevflareConfig['vars'], + sources: Record, + mode: EnvResolutionMode, + missing: MissingEnvVar[] +): DevflareConfig['vars'] { + if (!vars) { + return vars + } + + const resolved = resolveVarValue(vars, sources, [], mode, missing) + return resolved === OMIT_VALUE + ? undefined + : resolved as DevflareConfig['vars'] +} + +export interface ResolveConfigEnvVarsOptions { + /** + * Directory used to resolve relative config paths. + * + * @default process.cwd() + */ + cwd: string + + /** + * Optional config path. When present, `.env` discovery starts from the + * config file's directory rather than `cwd`. + */ + configPath?: string + + /** + * Resolution mode. Build mode fails on missing required variables; dev mode + * may use `.dev()` defaults before reporting missing values. + * + * @default 'build' + */ + mode: EnvResolutionMode +} + +/** + * Resolve all `env.NAME` descriptors under a config's `vars` object. + * + * @throws {EnvVarResolutionError} When required environment variables are missing. + * @throws {EnvVarParseError} When a descriptor parser throws. + * + * @example + * ```ts + * const resolved = await resolveConfigEnvVars(config, { + * cwd: process.cwd(), + * mode: 'build' + * }) + * ``` + */ +export async function resolveConfigEnvVars( + config: TConfig, + options: ResolveConfigEnvVarsOptions +): Promise { + const startDir = options.configPath ? dirname(resolve(options.cwd, options.configPath)) : options.cwd + const dotenv = await loadDevflareDotenv(startDir) + const sources: Record = { + ...dotenv.values, + ...process.env + } + const missing: MissingEnvVar[] = [] + const vars = resolveVarsObject(config.vars, sources, options.mode, missing) + + if (missing.length > 0) { + throw new EnvVarResolutionError(missing, options.mode) + } + + return vars === config.vars + ? config + : { + ...config, + vars + } as TConfig +} diff --git a/packages/devflare/src/config/framework-providers.ts b/packages/devflare/src/config/framework-providers.ts new file mode 100644 index 0000000..6ceab6b --- /dev/null +++ b/packages/devflare/src/config/framework-providers.ts @@ -0,0 +1,162 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'pathe' +import type { DevflareConfig } from './schema' + +type InferredConfigFragment = Pick + +interface FrameworkProviderContext { + cwd: string + config: DevflareConfig + configPath: string +} + +interface FrameworkProviderResolution { + id: string + config: InferredConfigFragment +} + +interface FrameworkProvider { + id: string + resolve(context: FrameworkProviderContext): Promise +} + +const SVELTE_CONFIG_FILES = [ + 'svelte.config.js', + 'svelte.config.mjs', + 'svelte.config.ts', + 'svelte.config.cjs' +] as const + +async function readTextIfExists(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + +async function readPackageDependencies(cwd: string): Promise | null> { + const packageJsonText = await readTextIfExists(join(cwd, 'package.json')) + if (!packageJsonText) { + return null + } + + try { + const packageJson = JSON.parse(packageJsonText) as { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + } + + return { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + ...(packageJson.peerDependencies ?? {}) + } + } catch { + return null + } +} + +async function findFirstExistingTextFile(cwd: string, candidates: readonly string[]): Promise { + for (const candidate of candidates) { + const fileText = await readTextIfExists(join(cwd, candidate)) + if (fileText !== null) { + return fileText + } + } + + return null +} + +const svelteKitCloudflareProvider: FrameworkProvider = { + id: 'sveltekit-cloudflare', + async resolve(context) { + const dependencies = await readPackageDependencies(context.cwd) + if (!dependencies?.['@sveltejs/kit'] || !dependencies['@sveltejs/adapter-cloudflare']) { + return null + } + + const svelteConfigText = await findFirstExistingTextFile(context.cwd, SVELTE_CONFIG_FILES) + if (!svelteConfigText) { + return null + } + + if (!/@sveltejs\/adapter-cloudflare|adapter-cloudflare/.test(svelteConfigText)) { + return null + } + + return { + id: this.id, + config: { + files: { + fetch: '.adapter-cloudflare/_worker.js' + }, + assets: { + binding: 'ASSETS', + directory: '.adapter-cloudflare' + } + } + } + } +} + +const frameworkProviders: readonly FrameworkProvider[] = [ + svelteKitCloudflareProvider +] + +function hasFrameworkInferenceGap(config: DevflareConfig): boolean { + return ( + config.files?.fetch === undefined + || config.assets?.directory === undefined + || config.assets?.binding === undefined + ) +} + +function mergeInferredConfig( + config: DevflareConfig, + inferredConfig: InferredConfigFragment +): DevflareConfig { + const mergedFiles = inferredConfig.files + ? { + ...inferredConfig.files, + ...(config.files ?? {}) + } + : config.files + + const mergedAssets = inferredConfig.assets + ? { + ...inferredConfig.assets, + ...(config.assets ?? {}) + } + : config.assets + + return { + ...config, + ...(mergedFiles && { files: mergedFiles }), + ...(mergedAssets && { assets: mergedAssets }) + } +} + +export async function applyFrameworkConfigProviders( + config: DevflareConfig, + context: Omit +): Promise { + if (!hasFrameworkInferenceGap(config)) { + return config + } + + for (const provider of frameworkProviders) { + const resolution = await provider.resolve({ + ...context, + config + }) + if (!resolution) { + continue + } + + return mergeInferredConfig(config, resolution.config) + } + + return config +} \ No newline at end of file diff --git a/packages/devflare/src/config/index.ts b/packages/devflare/src/config/index.ts new file mode 100644 index 0000000..3f615a0 --- /dev/null +++ b/packages/devflare/src/config/index.ts @@ -0,0 +1,159 @@ +// ============================================================================= +// Config Module โ€” Public exports +// ============================================================================= + +export { defineConfig, type DefineConfigInput, type TypedConfig } from './define' +export { + env, + isEnvVarDescriptor, + loadDevflareDotenv, + loadDevflareDotenvIntoProcess, + parseDevflareEnvFile, + resolveConfigEnvVars, + EnvVarResolutionError, + EnvVarParseError, + type DevflareVarInput, + type DevflareVarsInput, + type EnvResolutionMode, + type EnvVarDescriptor, + type InferConfigVars, + type MissingEnvVar, + type ResolveConfigEnvVarsOptions +} from './env-vars' +export { + preview, + isPreviewScopedName, + resolvePreviewIdentifier, + materializePreviewScopedConfig, + materializePreviewScopedString, + type ResolvedPreviewIdentifier, + type PreviewIdentifierSource, + type PreviewScopeFn, + type PreviewScopeOptions, + type PreviewScopedName, + type PreviewScopedNameOptions, + type PreviewResolutionOptions +} from './preview' +export { + configSchema, + getLocalHyperdriveConfigIdentifier, + getLocalKVNamespaceIdentifier, + getSingleBrowserBindingName, + getLocalD1DatabaseIdentifier, + normalizeHyperdriveBinding, + normalizeKVBinding, + normalizeD1Binding, + normalizeDOBinding, + normalizeMtlsCertificateBinding, + normalizeDispatchNamespaceBinding, + normalizeWorkflowBinding, + normalizePipelineBinding, + normalizeImagesBinding, + normalizeMediaBinding, + normalizeSecretsStoreBinding, + normalizeArtifactsBinding, + type BrowserBindings, + type D1Binding, + type HyperdriveBinding, + type DevflareConfig, + type DevflareConfigInput, + type DevflareEnvConfig, + type PreviewConfig, + type DurableObjectBinding, + type KVBinding, + type NormalizedHyperdriveBinding, + type NormalizedKVBinding, + type NormalizedD1Binding, + type NormalizedDOBinding, + type NormalizedMtlsCertificateBinding, + type NormalizedDispatchNamespaceBinding, + type NormalizedWorkflowBinding, + type NormalizedPipelineBinding, + type NormalizedImagesBinding, + type NormalizedMediaBinding, + type NormalizedSecretsStoreBinding, + type NormalizedArtifactsBinding, + type QueueConsumer, + type QueuesConfig, + type RateLimitBinding, + type VersionMetadataBinding, + type WorkerLoaderBinding, + type SecretsStoreBinding, + type MtlsCertificateBinding, + type DispatchNamespaceBinding, + type WorkflowBinding, + type PipelineBinding, + type ImagesBinding, + type MediaBinding, + type ArtifactsBinding, + type ServiceBinding, + type RouteConfig, + type TailConsumerConfig, + type WsRouteConfig, + type AssetsConfig, + type ContainerConfig, + type ViteConfig, + type RolldownConfig, + type MigrationConfig +} from './schema' +export { + compileBuildConfig, + compileConfig, + readWranglerConfig, + stringifyConfig, + writeWranglerConfig, + type WranglerConfig, + type WranglerD1DatabaseBinding, + type WranglerHyperdriveBinding, + type WranglerKVNamespaceBinding +} from './compiler' +export { + loadConfig, + loadResolvedConfig, + resolveConfigPath, + ConfigNotFoundError, + ConfigValidationError, + ConfigResourceResolutionError, + type LoadConfigOptions +} from './loader' +export { resolveConfigForEnvironment } from './resolve' +export { + resolveMaterializedConfigResources, + type LoadResolvedConfigOptions, + type ResolveMaterializedConfigResourcesOptions +} from './resource-resolution' +export { + prepareConfigResourcesForDeploy, + prepareMaterializedConfigResourcesForDeploy, + type DeployResourceNames, + type PrepareConfigResourcesForDeployOptions, + type PrepareConfigResourcesForDeployResult, + type PrepareMaterializedConfigResourcesForDeployOptions +} from './deploy-resources' +export { + resolveResources, + type BuildConfig, + type LocalConfig, + type DeployConfig, + type Phase, + type PhaseConfig, + type ResolveResourcesOptions, + type ResolveResourcesBuildOptions, + type ResolveResourcesLocalOptions, + type ResolveResourcesDeployOptions +} from './resolve-phased' +export { + collectReferencedServiceNames, + validateServiceBindings, + ServiceBindingValidationError, + type ValidateServiceBindingsOptions +} from './service-bindings-validation' + +// Cross-config referencing +export { + ref, + type RefResult, + type WorkerBinding, + type WorkerBindingAccessor, + type DOBindingRef +} from './ref' diff --git a/packages/devflare/src/config/loader.ts b/packages/devflare/src/config/loader.ts new file mode 100644 index 0000000..72161b2 --- /dev/null +++ b/packages/devflare/src/config/loader.ts @@ -0,0 +1,164 @@ +// ============================================================================= +// Config Loader โ€” Load devflare.config.ts via c12 +// ============================================================================= + +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'pathe' +import { applyFrameworkConfigProviders } from './framework-providers' +import { configSchema, type DevflareConfig } from './schema' +import { loadDevflareDotenvIntoProcess } from './env-vars' + +type C12LoadConfig = typeof import('c12')['loadConfig'] + +interface ResolvedC12Module { + loadConfig: C12LoadConfig +} + +/** + * Options for loading config + */ +export interface LoadConfigOptions { + /** Working directory to search for config */ + cwd?: string + /** Custom config file name */ + configFile?: string + /** Environment name for env-specific overrides */ + environment?: string +} + +/** + * Config file names to search for, in order of priority + */ +const CONFIG_FILES = [ + 'devflare.config.ts', + 'devflare.config.mts', + 'devflare.config.js', + 'devflare.config.mjs' +] + +function resolveC12Module(cwd: string): ResolvedC12Module { + const requireFromCwd = createRequire(join(cwd, '__devflare__.cjs')) + + try { + return requireFromCwd('c12') as ResolvedC12Module + } catch { + return createRequire(import.meta.url)('c12') as ResolvedC12Module + } +} + +async function resolveConfigDotenvDirectory(cwd: string, configFile: string): Promise { + const explicitConfigPath = resolve(cwd, configFile) + if (existsSync(explicitConfigPath)) { + return dirname(explicitConfigPath) + } + + const discoveredConfigPath = await resolveConfigPath(cwd) + return discoveredConfigPath ? dirname(discoveredConfigPath) : cwd +} + +/** + * Resolve the config file path in a directory + * + * @param cwd - Directory to search in + * @returns Path to config file or undefined + */ +export async function resolveConfigPath(cwd: string): Promise { + for (const file of CONFIG_FILES) { + const path = join(cwd, file) + if (existsSync(path)) { + return path + } + } + return undefined +} + +/** + * Load and validate devflare configuration + * + * @param options - Loading options + * @returns Validated DevflareConfig + * @throws When config file not found or validation fails + */ +export async function loadConfig(options: LoadConfigOptions = {}): Promise { + const cwd = resolve(options.cwd ?? process.cwd()) + const configFile = options.configFile ?? 'devflare.config' + const { loadConfig: c12LoadConfig } = resolveC12Module(cwd) + + await loadDevflareDotenvIntoProcess(await resolveConfigDotenvDirectory(cwd, configFile)) + + // Resolve c12 from the target project so generated Vite configs and other + // repo-local Devflare entrypoints can still load app configs in CI where the + // app installs devflare's dependencies inside its own node_modules tree. + const { config, configFile: loadedFile } = await c12LoadConfig({ + name: 'devflare', + cwd, + configFile, + defaultConfig: undefined, + rcFile: false, + globalRc: false, + dotenv: false + }) + + // Check if config was found + if (!config || !loadedFile) { + throw new ConfigNotFoundError(cwd, configFile) + } + + // Validate config + const result = configSchema.safeParse(config) + if (!result.success) { + throw new ConfigValidationError(result.error.issues, loadedFile) + } + + return applyFrameworkConfigProviders(result.data, { + cwd, + configPath: loadedFile + }) +} + +/** + * Error thrown when config file is not found + */ +export class ConfigNotFoundError extends Error { + readonly code = 'CONFIG_NOT_FOUND' + + constructor( + public readonly cwd: string, + public readonly configFile: string + ) { + super( + `Config file not found in ${cwd}.\n` + + `Expected one of: ${CONFIG_FILES.join(', ')}\n` + + `Run 'devflare init' to create a new config.` + ) + this.name = 'ConfigNotFoundError' + } +} + +/** + * Error thrown when config validation fails + */ +export class ConfigValidationError extends Error { + readonly code = 'CONFIG_VALIDATION_ERROR' + + constructor( + public readonly issues: Array<{ path: (string | number)[]; message: string }>, + public readonly configFile: string + ) { + const issueMessages = issues + .map((i) => ` - ${i.path.join('.')}: ${i.message}`) + .join('\n') + + super( + `Invalid config in ${configFile}:\n${issueMessages}` + ) + this.name = 'ConfigValidationError' + } +} + +export { + loadResolvedConfig, + ConfigResourceResolutionError, + type LoadResolvedConfigOptions +} from './resource-resolution' diff --git a/packages/devflare/src/config/local-dev-vars.ts b/packages/devflare/src/config/local-dev-vars.ts new file mode 100644 index 0000000..fe54b3e --- /dev/null +++ b/packages/devflare/src/config/local-dev-vars.ts @@ -0,0 +1,160 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'pathe' +import type { DevflareConfig } from './schema' + +export interface LoadLocalDevVarsOptions { + cwd: string + configPath?: string + environment?: string + vars?: Record + secrets?: DevflareConfig['secrets'] + silent?: boolean +} + +function parseEnvValue(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + + if ( + (quote === '"' || quote === "'" || quote === '`') && + trimmed.endsWith(quote) && + trimmed.length >= 2 + ) { + const inner = trimmed.slice(1, -1) + return quote === '"' + ? inner + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + : inner + } + + const commentIndex = trimmed.search(/\s+#/) + return (commentIndex >= 0 ? trimmed.slice(0, commentIndex) : trimmed).trimEnd() +} + +function parseEnvFile(contents: string): Record { + const vars: Record = {} + + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const assignment = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trimStart() + : trimmed + const equalsIndex = assignment.indexOf('=') + if (equalsIndex <= 0) { + continue + } + + const key = assignment.slice(0, equalsIndex).trim() + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + continue + } + + vars[key] = parseEnvValue(assignment.slice(equalsIndex + 1)) + } + + return vars +} + +async function readOptionalEnvFile(filePath: string): Promise | null> { + try { + return parseEnvFile(await readFile(filePath, 'utf8')) + } catch (error) { + if ((error as { code?: unknown }).code === 'ENOENT') { + return null + } + + throw error + } +} + +async function loadWranglerCompatibleLocalVars( + cwd: string, + environment: string | undefined +): Promise> { + const environmentDevVars = environment + ? await readOptionalEnvFile(resolve(cwd, `.dev.vars.${environment}`)) + : null + if (environmentDevVars) { + return environmentDevVars + } + + const devVars = await readOptionalEnvFile(resolve(cwd, '.dev.vars')) + if (devVars) { + return devVars + } + + const envFiles = [ + '.env', + '.env.local', + ...(environment ? [`.env.${environment}`, `.env.${environment}.local`] : []) + ] + const merged: Record = {} + + for (const fileName of envFiles) { + const values = await readOptionalEnvFile(resolve(cwd, fileName)) + if (values) { + Object.assign(merged, values) + } + } + + return merged +} + +export function toWranglerSecretsConfig( + secrets: DevflareConfig['secrets'] | undefined +): { required: string[] } | undefined { + if (!secrets) { + return undefined + } + + const required = Object.entries(secrets) + .filter(([, config]) => config.required !== false) + .map(([name]) => name) + .sort() + + return required.length > 0 ? { required } : undefined +} + +export async function loadLocalDevVars( + options: LoadLocalDevVarsOptions +): Promise> { + const activeEnvironment = options.environment ?? process.env.CLOUDFLARE_ENV + const localVars = await loadWranglerCompatibleLocalVars(options.cwd, activeEnvironment) + const secretNames = toWranglerSecretsConfig(options.secrets)?.required + const filteredLocalVars = secretNames + ? Object.fromEntries(Object.entries(localVars).filter(([name]) => secretNames.includes(name))) + : localVars + + return { + ...(options.vars ?? {}), + ...filteredLocalVars + } +} + +export async function applyLocalDevVarsToConfig( + config: DevflareConfig, + options: Omit +): Promise { + const vars = await loadLocalDevVars({ + ...options, + vars: config.vars, + secrets: config.secrets + }) + + if (Object.keys(vars).length === 0) { + return config + } + + return { + ...config, + vars + } +} diff --git a/packages/devflare/src/config/preview-resources.ts b/packages/devflare/src/config/preview-resources.ts new file mode 100644 index 0000000..6b21d96 --- /dev/null +++ b/packages/devflare/src/config/preview-resources.ts @@ -0,0 +1,777 @@ +import { + createD1Database, + createKVNamespace, + createQueue, + createR2Bucket, + createVectorizeIndex, + deleteD1Database, + deleteHyperdrive, + deleteKVNamespace, + deleteQueue, + deleteR2Bucket, + deleteVectorizeIndex, + getPrimaryAccount, + listD1Databases, + listHyperdrives, + listKVNamespaces, + listQueues, + listR2Buckets, + listVectorizeIndexes, + type D1DatabaseInfo, + type HyperdriveConfigInfo, + type KVNamespaceInfo, + type QueueInfo, + type R2BucketInfo, + type VectorizeIndexInfo +} from '../cloudflare/account' +import { getEffectiveAccountId } from '../cloudflare/preferences' +import { + isPreviewScopedName, + materializePreviewScopedConfig, + materializePreviewScopedString, + type PreviewResolutionOptions +} from './preview' +import { mergeConfigForEnvironment } from './resolve' +import type { DevflareConfig } from './schema' + +export interface PreviewScopedResourceRef { + bindingName?: string + baseName: string + previewName: string + /** + * Hyperdrive-only: the binding explicitly opted in to reusing the base + * Hyperdrive configuration when no preview Hyperdrive exists in the + * account (via `previewFallback: 'base'` on the binding). + */ + allowBaseFallback?: boolean +} + +export interface PreviewScopedResourcePlan { + kv: PreviewScopedResourceRef[] + d1: PreviewScopedResourceRef[] + r2: PreviewScopedResourceRef[] + queues: PreviewScopedResourceRef[] + vectorize: PreviewScopedResourceRef[] + hyperdrive: PreviewScopedResourceRef[] + analyticsEngine: PreviewScopedResourceRef[] + browser: PreviewScopedResourceRef[] +} + +export interface PreviewScopedResourceNames { + kv: string[] + d1: string[] + r2: string[] + queues: string[] + vectorize: string[] + hyperdrive: string[] + analyticsEngine: string[] + browser: string[] +} + +export interface PreparePreviewScopedResourcesForDeployResult { + accountId?: string + config: DevflareConfig + resourceResolutionCloudflare?: Pick< + PreviewScopedResourceLifecycleApi, + 'listKVNamespaces' | 'listD1Databases' | 'listHyperdrives' + > + plan: PreviewScopedResourcePlan + created: PreviewScopedResourceNames + existing: PreviewScopedResourceNames + warnings: string[] +} + +export interface CleanupPreviewScopedResourcesResult { + accountId?: string + plan: PreviewScopedResourcePlan + candidates: PreviewScopedResourceNames + deleted: PreviewScopedResourceNames + warnings: string[] +} + +interface PreviewScopedResourceLifecycleApi { + getPrimaryAccount: typeof getPrimaryAccount + getEffectiveAccountId: typeof getEffectiveAccountId + listKVNamespaces: typeof listKVNamespaces + createKVNamespace: typeof createKVNamespace + deleteKVNamespace: typeof deleteKVNamespace + listD1Databases: typeof listD1Databases + createD1Database: typeof createD1Database + deleteD1Database: typeof deleteD1Database + listR2Buckets: typeof listR2Buckets + createR2Bucket: typeof createR2Bucket + deleteR2Bucket: typeof deleteR2Bucket + listQueues: typeof listQueues + createQueue: typeof createQueue + deleteQueue: typeof deleteQueue + listVectorizeIndexes: typeof listVectorizeIndexes + createVectorizeIndex: typeof createVectorizeIndex + deleteVectorizeIndex: typeof deleteVectorizeIndex + listHyperdrives: typeof listHyperdrives + deleteHyperdrive: typeof deleteHyperdrive +} + +interface PreviewScopedResourceLifecycleState { + namespaces: KVNamespaceInfo[] + databases: D1DatabaseInfo[] + buckets: R2BucketInfo[] + queues: QueueInfo[] + vectorizeIndexes: VectorizeIndexInfo[] + hyperdrives: HyperdriveConfigInfo[] +} + +export interface PreviewScopedResourceLifecycleOptions extends PreviewResolutionOptions { + accountId?: string + cloudflare?: Partial +} + +const defaultPreviewScopedResourceLifecycleApi: PreviewScopedResourceLifecycleApi = { + getPrimaryAccount, + getEffectiveAccountId, + listKVNamespaces, + createKVNamespace, + deleteKVNamespace, + listD1Databases, + createD1Database, + deleteD1Database, + listR2Buckets, + createR2Bucket, + deleteR2Bucket, + listQueues, + createQueue, + deleteQueue, + listVectorizeIndexes, + createVectorizeIndex, + deleteVectorizeIndex, + listHyperdrives, + deleteHyperdrive +} + +function resolvePreviewScopedResourceLifecycleApi( + overrides: Partial | undefined +): PreviewScopedResourceLifecycleApi { + return { + ...defaultPreviewScopedResourceLifecycleApi, + ...(overrides ?? {}) + } +} + +function createEmptyPreviewScopedResourceNames(): PreviewScopedResourceNames { + return { + kv: [], + d1: [], + r2: [], + queues: [], + vectorize: [], + hyperdrive: [], + analyticsEngine: [], + browser: [] + } +} + +function createEmptyPreviewScopedResourcePlan(): PreviewScopedResourcePlan { + return { + kv: [], + d1: [], + r2: [], + queues: [], + vectorize: [], + hyperdrive: [], + analyticsEngine: [], + browser: [] + } +} + +function createPreviewScopedResourceRef( + value: string, + bindingName: string | undefined, + options: PreviewResolutionOptions +): PreviewScopedResourceRef | null { + if (!isPreviewScopedName(value)) { + return null + } + + const baseName = materializePreviewScopedString(value, { + env: {} + }) + const previewName = materializePreviewScopedString(value, options) + + if (!baseName || !previewName || baseName === previewName) { + return null + } + + return { + ...(bindingName ? { bindingName } : {}), + baseName, + previewName + } +} + +function upsertPreviewScopedQueueRef( + queueRefs: Map, + value: string, + options: PreviewResolutionOptions +): void { + const ref = createPreviewScopedResourceRef(value, undefined, options) + if (!ref) { + return + } + + if (!queueRefs.has(ref.previewName)) { + queueRefs.set(ref.previewName, ref) + } +} + +function applyHyperdriveBindingFallbacks( + config: DevflareConfig, + hyperdriveBindingFallbacks: Record +): DevflareConfig { + if (!config.bindings?.hyperdrive || Object.keys(hyperdriveBindingFallbacks).length === 0) { + return config + } + + return { + ...config, + bindings: { + ...config.bindings, + hyperdrive: Object.fromEntries( + Object.entries(config.bindings.hyperdrive).map(([bindingName, bindingConfig]) => { + const fallbackName = hyperdriveBindingFallbacks[bindingName] + if (!fallbackName) { + return [bindingName, bindingConfig] + } + + // Collapse any form (string or object) to the base-config + // string once the binding has been resolved to the base + // Hyperdrive. Object-form bindings carry preview opt-in + // metadata that no longer applies post-resolution. + return [bindingName, fallbackName] + }) + ) + } + } +} + +function resolvePreviewScopedResourceLifecycleWarnings(plan: PreviewScopedResourcePlan): string[] { + const warnings: string[] = [] + + if (plan.analyticsEngine.length > 0) { + warnings.push( + 'Workers Analytics Engine datasets are created automatically on first write, so Devflare does not provision or delete preview-scoped analytics datasets.' + ) + } + + if (plan.browser.length > 0) { + warnings.push( + 'Browser Rendering bindings do not own account-scoped resources, so Devflare does not provision or delete preview-scoped browser bindings.' + ) + } + + return warnings +} + +function hasPreviewScopedLifecycleResources(plan: PreviewScopedResourcePlan): boolean { + return plan.kv.length > 0 + || plan.d1.length > 0 + || plan.r2.length > 0 + || plan.queues.length > 0 + || plan.vectorize.length > 0 + || plan.hyperdrive.length > 0 +} + +async function resolveLifecycleAccountId( + config: DevflareConfig, + options: PreviewScopedResourceLifecycleOptions, + cloudflareApi: PreviewScopedResourceLifecycleApi +): Promise { + if (options.accountId?.trim()) { + return options.accountId.trim() + } + + if (config.accountId?.trim()) { + return config.accountId.trim() + } + + const primaryAccount = await cloudflareApi.getPrimaryAccount() + if (!primaryAccount) { + throw new Error( + 'Could not resolve a Cloudflare account for preview-scoped resource lifecycle management. Set accountId in devflare.config.ts, pass --account, or authenticate with Wrangler.' + ) + } + + const effective = await cloudflareApi.getEffectiveAccountId(primaryAccount.id) + return effective.accountId +} + +async function loadPreviewScopedResourceLifecycleState( + accountId: string, + plan: PreviewScopedResourcePlan, + cloudflareApi: PreviewScopedResourceLifecycleApi +): Promise { + const [namespaces, databases, buckets, queues, vectorizeIndexes, hyperdrives] = await Promise.all([ + plan.kv.length > 0 ? cloudflareApi.listKVNamespaces(accountId) : Promise.resolve([] as KVNamespaceInfo[]), + plan.d1.length > 0 ? cloudflareApi.listD1Databases(accountId) : Promise.resolve([] as D1DatabaseInfo[]), + plan.r2.length > 0 ? cloudflareApi.listR2Buckets(accountId) : Promise.resolve([] as R2BucketInfo[]), + plan.queues.length > 0 ? cloudflareApi.listQueues(accountId) : Promise.resolve([] as QueueInfo[]), + plan.vectorize.length > 0 ? cloudflareApi.listVectorizeIndexes(accountId) : Promise.resolve([] as VectorizeIndexInfo[]), + plan.hyperdrive.length > 0 ? cloudflareApi.listHyperdrives(accountId) : Promise.resolve([] as HyperdriveConfigInfo[]) + ]) + + return { + namespaces, + databases, + buckets, + queues, + vectorizeIndexes, + hyperdrives + } +} + +function createPreviewScopedResourceResolutionCloudflareApi( + state: Pick +): Pick< + PreviewScopedResourceLifecycleApi, + 'listKVNamespaces' | 'listD1Databases' | 'listHyperdrives' +> { + return { + listKVNamespaces: async () => state.namespaces, + listD1Databases: async () => state.databases, + listHyperdrives: async () => state.hyperdrives + } +} + +function findVectorizeIndexByName( + indexes: VectorizeIndexInfo[], + name: string +): VectorizeIndexInfo | undefined { + return indexes.find((index) => index.name === name) +} + +function findKVNamespaceByName( + namespaces: KVNamespaceInfo[], + name: string +): KVNamespaceInfo | undefined { + return namespaces.find((namespace) => namespace.name === name) +} + +function findD1DatabaseByName( + databases: D1DatabaseInfo[], + name: string +): D1DatabaseInfo | undefined { + return databases.find((database) => database.name === name) +} + +function findR2BucketByName( + buckets: R2BucketInfo[], + name: string +): R2BucketInfo | undefined { + return buckets.find((bucket) => bucket.name === name) +} + +function findQueueByName( + queues: QueueInfo[], + name: string +): QueueInfo | undefined { + return queues.find((queue) => queue.name === name) +} + +function findHyperdriveByName( + hyperdrives: HyperdriveConfigInfo[], + name: string +): HyperdriveConfigInfo | undefined { + return hyperdrives.find((hyperdrive) => hyperdrive.name === name) +} + +export function collectPreviewScopedResourcePlan( + config: DevflareConfig, + options: PreviewResolutionOptions = {} +): PreviewScopedResourcePlan { + const mergedConfig = mergeConfigForEnvironment(config, options.environment) + const plan = createEmptyPreviewScopedResourcePlan() + const bindings = mergedConfig.bindings + + if (!bindings) { + return plan + } + + if (bindings.kv) { + plan.kv = Object.entries(bindings.kv) + .map(([bindingName, bindingConfig]) => { + return typeof bindingConfig === 'string' + ? createPreviewScopedResourceRef(bindingConfig, bindingName, options) + : null + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.d1) { + plan.d1 = Object.entries(bindings.d1) + .map(([bindingName, bindingConfig]) => { + return typeof bindingConfig === 'string' + ? createPreviewScopedResourceRef(bindingConfig, bindingName, options) + : null + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.r2) { + plan.r2 = Object.entries(bindings.r2) + .map(([bindingName, bindingConfig]) => { + return createPreviewScopedResourceRef(bindingConfig, bindingName, options) + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.queues) { + const queueRefs = new Map() + + for (const queueName of Object.values(bindings.queues.producers ?? {})) { + upsertPreviewScopedQueueRef(queueRefs, queueName, options) + } + + for (const consumer of bindings.queues.consumers ?? []) { + upsertPreviewScopedQueueRef(queueRefs, consumer.queue, options) + if (consumer.deadLetterQueue) { + upsertPreviewScopedQueueRef(queueRefs, consumer.deadLetterQueue, options) + } + } + + plan.queues = Array.from(queueRefs.values()) + } + + if (bindings.vectorize) { + plan.vectorize = Object.entries(bindings.vectorize) + .map(([bindingName, bindingConfig]) => { + return createPreviewScopedResourceRef(bindingConfig.indexName, bindingName, options) + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.hyperdrive) { + plan.hyperdrive = Object.entries(bindings.hyperdrive) + .map(([bindingName, bindingConfig]) => { + if (typeof bindingConfig === 'string') { + return createPreviewScopedResourceRef(bindingConfig, bindingName, options) + } + if ( + bindingConfig + && typeof bindingConfig === 'object' + && 'name' in bindingConfig + && typeof bindingConfig.name === 'string' + ) { + if ( + 'previewId' in bindingConfig + && typeof bindingConfig.previewId === 'string' + && bindingConfig.previewId.trim() + ) { + return null + } + const ref = createPreviewScopedResourceRef(bindingConfig.name, bindingName, options) + if (ref && (bindingConfig as { previewFallback?: unknown }).previewFallback === 'base') { + ref.allowBaseFallback = true + } + return ref + } + return null + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.analyticsEngine) { + plan.analyticsEngine = Object.entries(bindings.analyticsEngine) + .map(([bindingName, bindingConfig]) => { + return createPreviewScopedResourceRef(bindingConfig.dataset, bindingName, options) + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + if (bindings.browser) { + plan.browser = Object.entries(bindings.browser) + .map(([bindingName, bindingConfig]) => { + if (typeof bindingConfig !== 'string') { + return null + } + return createPreviewScopedResourceRef(bindingConfig, bindingName, options) + }) + .filter((ref): ref is PreviewScopedResourceRef => ref !== null) + } + + return plan +} + +export async function preparePreviewScopedResourcesForDeploy( + config: DevflareConfig, + options: PreviewScopedResourceLifecycleOptions = {} +): Promise { + const mergedConfig = mergeConfigForEnvironment(config, options.environment) + const plan = collectPreviewScopedResourcePlan(config, options) + const created = createEmptyPreviewScopedResourceNames() + const existing = createEmptyPreviewScopedResourceNames() + const warnings = resolvePreviewScopedResourceLifecycleWarnings(plan) + + if (!hasPreviewScopedLifecycleResources(plan)) { + return { + config: materializePreviewScopedConfig(mergedConfig, options), + plan, + created, + existing, + warnings + } + } + + const cloudflareApi = resolvePreviewScopedResourceLifecycleApi(options.cloudflare) + const accountId = await resolveLifecycleAccountId(config, options, cloudflareApi) + + const { + namespaces, + databases, + buckets, + queues, + vectorizeIndexes, + hyperdrives + } = await loadPreviewScopedResourceLifecycleState(accountId, plan, cloudflareApi) + + for (const ref of plan.kv) { + if (findKVNamespaceByName(namespaces, ref.previewName)) { + existing.kv.push(ref.previewName) + continue + } + + const namespace = await cloudflareApi.createKVNamespace(accountId, ref.previewName) + created.kv.push(ref.previewName) + namespaces.push(namespace) + } + + for (const ref of plan.d1) { + if (findD1DatabaseByName(databases, ref.previewName)) { + existing.d1.push(ref.previewName) + continue + } + + const database = await cloudflareApi.createD1Database(accountId, ref.previewName) + created.d1.push(ref.previewName) + databases.push(database) + } + + for (const ref of plan.r2) { + if (findR2BucketByName(buckets, ref.previewName)) { + existing.r2.push(ref.previewName) + continue + } + + const bucket = await cloudflareApi.createR2Bucket(accountId, ref.previewName) + created.r2.push(ref.previewName) + buckets.push(bucket) + } + + for (const ref of plan.queues) { + if (findQueueByName(queues, ref.previewName)) { + existing.queues.push(ref.previewName) + continue + } + + const queue = await cloudflareApi.createQueue(accountId, ref.previewName) + created.queues.push(ref.previewName) + queues.push(queue) + } + + for (const ref of plan.vectorize) { + if (findVectorizeIndexByName(vectorizeIndexes, ref.previewName)) { + existing.vectorize.push(ref.previewName) + continue + } + + const baseIndex = findVectorizeIndexByName(vectorizeIndexes, ref.baseName) + if (!baseIndex) { + throw new Error( + `Could not provision preview Vectorize index "${ref.previewName}" because the base index "${ref.baseName}" was not found.` + ) + } + + const createdIndex = await cloudflareApi.createVectorizeIndex(accountId, { + name: ref.previewName, + dimensions: baseIndex.dimensions, + metric: baseIndex.metric, + description: baseIndex.description + }) + created.vectorize.push(ref.previewName) + vectorizeIndexes.push(createdIndex) + } + + const hyperdriveBindingFallbacks: Record = {} + + for (const ref of plan.hyperdrive) { + if (findHyperdriveByName(hyperdrives, ref.previewName)) { + existing.hyperdrive.push(ref.previewName) + continue + } + + if (!findHyperdriveByName(hyperdrives, ref.baseName)) { + throw new Error( + `Could not resolve preview Hyperdrive "${ref.previewName}" because neither the preview config nor the base config "${ref.baseName}" exists in this account.` + ) + } + + if (!ref.allowBaseFallback) { + const bindingLabel = ref.bindingName ? `"${ref.bindingName}"` : `for preview name "${ref.previewName}"` + throw new Error( + `Preview Hyperdrive binding ${bindingLabel} has no dedicated preview Hyperdrive configuration "${ref.previewName}" in this account. ` + + 'Either provision a dedicated preview Hyperdrive (or set `previewId` on the binding), ' + + "or opt in to reusing the base Hyperdrive by setting `previewFallback: 'base'` on the binding." + ) + } + + if (ref.bindingName) { + hyperdriveBindingFallbacks[ref.bindingName] = ref.baseName + } + + warnings.push( + `Preview Hyperdrive "${ref.previewName}" is not auto-provisioned because Cloudflare does not expose stored Hyperdrive credentials for cloning. Devflare will reuse the base Hyperdrive "${ref.baseName}" for binding ${ref.bindingName ?? ref.previewName}.` + ) + } + + const preparedConfig = applyHyperdriveBindingFallbacks(mergedConfig, hyperdriveBindingFallbacks) + + return { + accountId, + config: materializePreviewScopedConfig(preparedConfig, options), + resourceResolutionCloudflare: createPreviewScopedResourceResolutionCloudflareApi({ + namespaces, + databases, + hyperdrives + }), + plan, + created, + existing, + warnings + } +} + +export async function cleanupPreviewScopedResources( + config: DevflareConfig, + options: PreviewScopedResourceLifecycleOptions & { apply?: boolean } = {} +): Promise { + const plan = collectPreviewScopedResourcePlan(config, options) + const candidates = createEmptyPreviewScopedResourceNames() + const deleted = createEmptyPreviewScopedResourceNames() + const warnings = resolvePreviewScopedResourceLifecycleWarnings(plan) + + if (!hasPreviewScopedLifecycleResources(plan)) { + return { + plan, + candidates, + deleted, + warnings + } + } + + const cloudflareApi = resolvePreviewScopedResourceLifecycleApi(options.cloudflare) + const accountId = await resolveLifecycleAccountId(config, options, cloudflareApi) + const apply = options.apply === true + + const { + namespaces, + databases, + buckets, + queues, + vectorizeIndexes, + hyperdrives + } = await loadPreviewScopedResourceLifecycleState(accountId, plan, cloudflareApi) + + const kvCandidates = plan.kv + .map((ref) => findKVNamespaceByName(namespaces, ref.previewName)) + .filter((namespace): namespace is KVNamespaceInfo => namespace !== undefined) + for (const namespace of kvCandidates) { + candidates.kv.push(namespace.name) + if (!apply) { + continue + } + + await cloudflareApi.deleteKVNamespace(accountId, namespace.id) + deleted.kv.push(namespace.name) + } + + const d1Candidates = plan.d1 + .map((ref) => findD1DatabaseByName(databases, ref.previewName)) + .filter((database): database is D1DatabaseInfo => database !== undefined) + for (const database of d1Candidates) { + candidates.d1.push(database.name) + if (!apply) { + continue + } + + await cloudflareApi.deleteD1Database(accountId, database.id) + deleted.d1.push(database.name) + } + + const r2Candidates = plan.r2 + .map((ref) => findR2BucketByName(buckets, ref.previewName)) + .filter((bucket): bucket is R2BucketInfo => bucket !== undefined) + for (const bucket of r2Candidates) { + candidates.r2.push(bucket.name) + if (!apply) { + continue + } + + await cloudflareApi.deleteR2Bucket(accountId, bucket.name) + deleted.r2.push(bucket.name) + } + + const queueCandidates = plan.queues + .map((ref) => findQueueByName(queues, ref.previewName)) + .filter((queue): queue is QueueInfo => queue !== undefined) + for (const queue of queueCandidates) { + candidates.queues.push(queue.name) + if (!apply) { + continue + } + + if (!queue.id) { + warnings.push(`Skipping queue deletion for "${queue.name}" because Cloudflare did not return a queue id.`) + continue + } + + await cloudflareApi.deleteQueue(accountId, queue.id) + deleted.queues.push(queue.name) + } + + const vectorizeCandidates = plan.vectorize + .map((ref) => findVectorizeIndexByName(vectorizeIndexes, ref.previewName)) + .filter((index): index is VectorizeIndexInfo => index !== undefined) + for (const index of vectorizeCandidates) { + candidates.vectorize.push(index.name) + if (!apply) { + continue + } + + await cloudflareApi.deleteVectorizeIndex(accountId, index.name) + deleted.vectorize.push(index.name) + } + + const hyperdriveCandidates = plan.hyperdrive + .map((ref) => findHyperdriveByName(hyperdrives, ref.previewName)) + .filter((hyperdrive): hyperdrive is HyperdriveConfigInfo => hyperdrive !== undefined) + for (const hyperdrive of hyperdriveCandidates) { + candidates.hyperdrive.push(hyperdrive.name) + if (!apply) { + continue + } + + await cloudflareApi.deleteHyperdrive(accountId, hyperdrive.id) + deleted.hyperdrive.push(hyperdrive.name) + } + + if (plan.hyperdrive.length > 0) { + warnings.push( + 'Preview-scoped Hyperdrive cleanup only deletes preview configs that already exist. Devflare does not auto-provision preview Hyperdrives because Cloudflare does not expose stored Hyperdrive credentials for cloning.' + ) + } + + return { + accountId, + plan, + candidates, + deleted, + warnings + } +} diff --git a/packages/devflare/src/config/preview.ts b/packages/devflare/src/config/preview.ts new file mode 100644 index 0000000..3043eb3 --- /dev/null +++ b/packages/devflare/src/config/preview.ts @@ -0,0 +1,329 @@ +import type { DevflareConfig } from './schema' + +const PREVIEW_SCOPED_NAME_PREFIX = '__DEVFLARE_PREVIEW_SCOPE__:' + +export interface PreviewScopeOptions { + separator?: string +} + +export interface PreviewScopedNameOptions { + separator?: string +} + +interface EncodedPreviewScopedName { + baseName: string + separator: string +} + +export type PreviewScopedName = string & { + readonly __devflarePreviewScopedName: unique symbol +} + +export interface PreviewScopeFn { + (baseName: string, options?: PreviewScopedNameOptions): PreviewScopedName +} + +export interface PreviewResolutionOptions { + environment?: string + env?: Record + identifier?: string +} + +export type PreviewIdentifierSource = 'identifier' | 'env-identifier' | 'env-pr' | 'env-branch' | 'environment' | 'none' + +export interface ResolvedPreviewIdentifier { + identifier?: string + source: PreviewIdentifierSource +} + +function getPreviewScopedSeparator(options: PreviewScopedNameOptions | PreviewScopeOptions | undefined): string { + return options?.separator ?? '-' +} + +function encodePreviewScopedName(value: EncodedPreviewScopedName): PreviewScopedName { + return `${PREVIEW_SCOPED_NAME_PREFIX}${JSON.stringify(value)}` as PreviewScopedName +} + +function invalidPreviewScopedName(reason: string): never { + throw new Error( + `Invalid Devflare preview-scoped value: ${reason}. Recreate it with preview.scope(...) instead of constructing preview markers manually.` + ) +} + +function decodePreviewScopedName(value: PreviewScopedName): EncodedPreviewScopedName { + const payload = value.slice(PREVIEW_SCOPED_NAME_PREFIX.length) + let parsed: Partial + + try { + parsed = JSON.parse(payload) as Partial + } catch { + invalidPreviewScopedName('the encoded payload is not valid JSON') + } + + const baseName = typeof parsed.baseName === 'string' + ? parsed.baseName + : '' + + if (!baseName.trim()) { + invalidPreviewScopedName('the encoded payload is missing a non-empty baseName') + } + + return { + baseName, + separator: typeof parsed.separator === 'string' && parsed.separator.length > 0 + ? parsed.separator + : '-' + } +} + +function normalizePreviewFragment(rawValue: string): string { + let normalized = rawValue + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + + if (!normalized) { + normalized = 'preview' + } + + if (!/^[a-z]/.test(normalized)) { + normalized = `b-${normalized}` + } + + return normalized +} + +function getPreviewIdentifierFromEnv(env: Record): ResolvedPreviewIdentifier { + const explicitIdentifier = env.DEVFLARE_PREVIEW_IDENTIFIER?.trim() + if (explicitIdentifier) { + return { + identifier: normalizePreviewFragment(explicitIdentifier), + source: 'env-identifier' + } + } + + const previewPr = env.DEVFLARE_PREVIEW_PR?.trim() + if (previewPr) { + return { + identifier: normalizePreviewFragment(`pr-${previewPr}`), + source: 'env-pr' + } + } + + const previewBranch = env.DEVFLARE_PREVIEW_BRANCH?.trim() + if (previewBranch) { + return { + identifier: normalizePreviewFragment(previewBranch), + source: 'env-branch' + } + } + + return { + identifier: undefined, + source: 'none' + } +} + +export function resolvePreviewIdentifier(options: PreviewResolutionOptions = {}): ResolvedPreviewIdentifier { + if (options.identifier?.trim()) { + return { + identifier: normalizePreviewFragment(options.identifier), + source: 'identifier' + } + } + + const env = options.env ?? process.env + const envIdentifier = getPreviewIdentifierFromEnv(env) + if (envIdentifier.identifier) { + return envIdentifier + } + + return options.environment === 'preview' + ? { + identifier: 'preview', + source: 'environment' + } + : { + identifier: undefined, + source: 'none' + } +} + +function mapRecordValues( + record: Record, + mapper: (value: TValue) => TValue +): Record { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, mapper(value)]) + ) as Record +} + +export const preview = { + scope(defaults: PreviewScopeOptions = {}): PreviewScopeFn { + return (baseName: string, options: PreviewScopedNameOptions = {}) => { + if (!baseName.trim()) { + throw new Error('preview.scope(...) requires a non-empty baseName.') + } + + return encodePreviewScopedName({ + baseName, + separator: getPreviewScopedSeparator({ + ...defaults, + ...options + }) + }) + } + } +} + +export function isPreviewScopedName(value: unknown): value is PreviewScopedName { + return typeof value === 'string' && value.startsWith(PREVIEW_SCOPED_NAME_PREFIX) +} + +export function materializePreviewScopedString( + value: string, + options: PreviewResolutionOptions = {} +): string { + if (!isPreviewScopedName(value)) { + return value + } + + const scoped = decodePreviewScopedName(value) + const previewIdentifier = resolvePreviewIdentifier(options).identifier + + return previewIdentifier + ? `${scoped.baseName}${scoped.separator}${previewIdentifier}` + : scoped.baseName +} + +export function materializePreviewScopedConfig( + config: DevflareConfig, + options: PreviewResolutionOptions = {} +): DevflareConfig { + if (!config.bindings) { + return config + } + + const bindings = config.bindings + const hasPreviewIdentifier = Boolean(resolvePreviewIdentifier(options).identifier) + + return { + ...config, + bindings: { + ...bindings, + ...(bindings.kv + ? { + kv: mapRecordValues(bindings.kv, (binding) => { + return typeof binding === 'string' + ? materializePreviewScopedString(binding, options) + : binding + }) + } + : {}), + ...(bindings.d1 + ? { + d1: mapRecordValues(bindings.d1, (binding) => { + return typeof binding === 'string' + ? materializePreviewScopedString(binding, options) + : binding + }) + } + : {}), + ...(bindings.r2 + ? { + r2: mapRecordValues(bindings.r2, (binding) => { + return materializePreviewScopedString(binding, options) + }) + } + : {}), + ...(bindings.queues + ? { + queues: { + ...bindings.queues, + ...(bindings.queues.producers + ? { + producers: mapRecordValues(bindings.queues.producers, (queueName) => { + return materializePreviewScopedString(queueName, options) + }) + } + : {}), + ...(bindings.queues.consumers + ? { + consumers: bindings.queues.consumers.map((consumer) => ({ + ...consumer, + queue: materializePreviewScopedString(consumer.queue, options), + ...(consumer.deadLetterQueue + ? { + deadLetterQueue: materializePreviewScopedString(consumer.deadLetterQueue, options) + } + : {}) + })) + } + : {}) + } + } + : {}), + ...(bindings.services + ? { + services: mapRecordValues(bindings.services, (binding) => ({ + ...binding, + service: materializePreviewScopedString(binding.service, options) + })) + } + : {}), + ...(bindings.vectorize + ? { + vectorize: mapRecordValues(bindings.vectorize, (binding) => ({ + ...binding, + indexName: materializePreviewScopedString(binding.indexName, options) + })) + } + : {}), + ...(bindings.hyperdrive + ? { + hyperdrive: mapRecordValues(bindings.hyperdrive, (binding) => { + if (typeof binding === 'string') { + return materializePreviewScopedString(binding, options) + } + if (binding && typeof binding === 'object' && 'name' in binding && typeof binding.name === 'string') { + if (hasPreviewIdentifier && binding.previewId) { + return { + id: binding.previewId, + ...(binding.localConnectionString && { + localConnectionString: binding.localConnectionString + }), + ...(!binding.localConnectionString && binding.previewLocalConnectionString && { + localConnectionString: binding.previewLocalConnectionString + }) + } + } + return { + ...binding, + name: materializePreviewScopedString(binding.name, options) + } + } + return binding + }) + } + : {}), + ...(bindings.browser + ? { + browser: mapRecordValues(bindings.browser, (binding) => { + return typeof binding === 'string' + ? materializePreviewScopedString(binding, options) + : binding + }) + } + : {}), + ...(bindings.analyticsEngine + ? { + analyticsEngine: mapRecordValues(bindings.analyticsEngine, (binding) => ({ + ...binding, + dataset: materializePreviewScopedString(binding.dataset, options) + })) + } + : {}) + } + } +} diff --git a/packages/devflare/src/config/ref.ts b/packages/devflare/src/config/ref.ts new file mode 100644 index 0000000..5b68d64 --- /dev/null +++ b/packages/devflare/src/config/ref.ts @@ -0,0 +1,515 @@ +// ============================================================================= +// ref() โ€” Cross-config referencing for multi-worker setups +// ============================================================================= +// Provides type-safe references to other worker configs for service bindings +// and cross-worker Durable Object access. +// +// Usage in devflare.config.ts: +// const mathWorker = ref(() => import('./math-worker/devflare.config')) +// +// bindings: { +// services: { +// MATH_SERVICE: mathWorker.worker // Default worker.ts export +// ADMIN: mathWorker.worker('AdminEntrypoint') // Named entrypoint +// }, +// durableObjects: { +// COUNTER: doService.COUNTER // Cross-worker DO binding +// } +// } +// +// With explicit name override: +// const mathWorker = ref('custom-name', () => import('./math-worker/devflare.config')) +// +// Type Hints for Entrypoints: +// After running `devflare types`, the referenced config will have generated +// entrypoint types that enable autocomplete in .worker('...') calls. +// +// Naming Conventions: +// worker.ts โ€” Default worker export (transformed to WorkerEntrypoint) +// ep.*.ts โ€” Named entrypoints (classes extending WorkerEntrypoint) +// do.*.ts โ€” Durable Objects (classes extending DurableObject) +// wf.*.ts โ€” Workflows (classes extending Workflow) +// ============================================================================= + +import type { DevflareConfigInput } from './schema' +import type { TypedConfig } from './define' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * Extract entrypoint type from a TypedConfig + * Falls back to string if no type parameter was provided + */ +type ExtractEntrypoints = TConfig extends TypedConfig ? E : string + +/** + * Extract the config type from a dynamic import function + * Handles both `{ default: Config }` and direct `Config` module shapes + */ +type ExtractConfig = TImport extends () => Promise + ? TModule extends { default: infer TConfig } + ? TConfig + : TModule + : DevflareConfigInput + +/** + * Dynamic import function type for config modules + */ +type ConfigImport = + () => Promise<{ default: T } | T> + +/** + * Worker binding reference - returned by ref().worker or ref().worker('entrypoint') + */ +export interface WorkerBinding { + /** Worker name (resolved lazily) */ + readonly service: string + /** Entrypoint class name (if specified) */ + readonly entrypoint?: string + /** @internal Reference for test context setup - contains import function and metadata */ + readonly __ref?: RefResult +} + +/** + * Durable Object binding reference - returned by ref().DO_NAME + * Named differently from schema's DurableObjectBinding to avoid confusion + */ +export interface DOBindingRef { + /** DO class name */ + readonly className: string + /** + * Worker name that hosts this DO (for cross-worker access). + * + * Prefer the `kind` discriminator below for branching; reach for + * `scriptName` only when you need the actual script identifier. + */ + readonly scriptName: string + /** + * Discriminator: `ref()`-produced DO bindings are always + * `'cross-worker'` because they target a host worker imported via a + * separate `devflare.config`. Bindings on the same worker are emitted + * directly via `bindings.durableObjects` and surface as a + * `NormalizedDOBinding` with `kind: 'local'`. + */ + readonly kind: 'cross-worker' + /** @internal Reference for test context setup */ + readonly __ref?: RefResult +} + +/** + * Template literal type for matching uppercase DO binding names. + * This allows the index signature to return DOBindingRef for UPPER_CASE + * property access while keeping specific types for known properties. + */ +type UpperCaseName = `${Uppercase}` + +/** + * Accessor for worker bindings - can be accessed directly or called with entrypoint + * @template TEntrypoints - Union of valid entrypoint names from config + */ +export interface WorkerBindingAccessor extends WorkerBinding { + /** + * Get a service binding with a specific named entrypoint + * @param entrypoint - The entrypoint class name from ep.*.ts files + */ + (entrypoint: TEntrypoints): WorkerBinding +} + +/** + * Result of ref() - a lazy proxy to the referenced config + * Supports dynamic DO binding access via property lookup (e.g., ref.COUNTER) + */ +export interface RefResult { + /** + * The worker name from the config (or overridden) + * Accessing this triggers resolution if not already resolved. + */ + readonly name: string + + /** + * Raw config object (for advanced usage) + * Accessing this triggers resolution if not already resolved. + */ + readonly config: TConfig + + /** + * Path to the config file (for resolution) + */ + readonly configPath: string + + /** + * Get a service binding to this worker's default export (WorkerEntrypoint) + * Call as function to specify entrypoint: .worker('AdminEntrypoint') + * Or access directly for default export: .worker + */ + readonly worker: WorkerBindingAccessor> + + /** + * @internal The import function for lazy resolution + */ + readonly __import: ConfigImport + + /** + * @internal Optional name override + */ + readonly __nameOverride?: string + + /** + * Resolve the reference and get the config + */ + resolve(): Promise<{ name: string; config: TConfig; configPath: string }> + + /** + * Dynamic DO binding access: ref.COUNTER, ref.RATE_LIMITER, etc. + * Returns a DOBindingRef for cross-worker DO access. + * Uses template literal type to match UPPER_CASE binding names only. + */ + readonly [K: UpperCaseName]: DOBindingRef +} + +// ----------------------------------------------------------------------------- +// Internal State โ€” Resolution Cache +// ----------------------------------------------------------------------------- + +interface ResolvedData { + name: string + config: TConfig + configPath: string +} + +const resolvedCache = new WeakMap() +const pendingResolutions = new WeakMap>() +const PENDING_REF_VALUE = '' + +// ----------------------------------------------------------------------------- +// Config Path Extraction +// ----------------------------------------------------------------------------- + +/** + * Extract the import specifier string from an import-thunk function's source. + * + * Uses a narrow regex over `fn.toString()`. To avoid returning bogus paths for + * minified or hand-written functions that do not contain a parseable + * `import(...)` call, the result is validated before being returned. + * + * Throws a clear error instead of returning a silent placeholder when the + * function source is not in a recognized shape. + */ +function extractConfigPathFromImportFn( + fn: (...args: unknown[]) => unknown +): string { + let source: string + try { + source = Function.prototype.toString.call(fn) + } catch { + // Exotic function (bound/native/Proxy) โ€” treat as unresolved until + // runtime resolution and fail loudly only when the path is actually + // needed. + return PENDING_REF_VALUE + } + + // Functions that do not contain a dynamic `import(...)` at all (e.g. the + // mock thunks used in tests and in programmatic test contexts) are treated + // as having a pending config path โ€” not an error. The path is only + // consulted by consumers that need it and those consumers already handle + // the pending sentinel. + if (!/import\s*\(/.test(source)) { + return PENDING_REF_VALUE + } + + const match = source.match(/import\s*\(\s*(['"`])([^'"`]+)\1\s*\)/) + const raw = match?.[2] + + if (!raw || raw.length === 0) { + throw new Error( + 'ref() could not extract a config path from the import function source. ' + + 'The specifier must be a static string literal โ€” dynamic or computed ' + + 'specifiers (e.g. template literals with expressions) are not supported. ' + + 'If this input has been minified, pass an unminified config source.' + ) + } + + // Reject template literals with embedded expressions โ€” the resulting + // path is dynamic and can only be resolved at runtime. + if (match?.[1] === '`' && /\$\{/.test(raw)) { + throw new Error( + 'ref() import specifier is a template literal with an embedded expression. ' + + 'The specifier must be a static string literal so the config path can ' + + 'be resolved ahead of time.' + ) + } + + // Obvious minification artefact: a 1-char specifier with no separator or + // extension is almost certainly the product of a bundler rewriting the + // original literal. Refuse to guess. + if (raw.length < 2 && !/[./]/.test(raw)) { + throw new Error( + `ref() extracted a suspiciously short config path (${JSON.stringify(raw)}). ` + + 'This usually indicates a minified bundle where the original specifier ' + + 'was rewritten. Pass an unminified config source.' + ) + } + + return raw +} + +// ----------------------------------------------------------------------------- +// Implementation +// ----------------------------------------------------------------------------- + +/** + * Create a typed reference to another worker's config. + * Returns a lazy proxy - the import is resolved only when needed. + * + * @param nameOrImport - Worker name override, OR the import function + * @param maybeImport - Import function (if first arg is name) + * @returns RefResult proxy with lazy access to config metadata + * + * @example + * // Basic usage - name from config + * const mathWorker = ref(() => import('./math-worker/devflare.config')) + * + * export default defineConfig({ + * bindings: { + * services: { + * MATH: mathWorker.worker // Default export + * // or: mathWorker.worker('MathService') // Specific entrypoint + * } + * } + * }) + * + * @example + * // With name override + * const mathWorker = ref('custom-math', () => import('./math-worker/devflare.config')) + */ +export function ref Promise<{ default: DevflareConfigInput } | DevflareConfigInput>>( + nameOrImport: string | TImport, + maybeImport?: TImport +): RefResult> +export function ref Promise<{ default: DevflareConfigInput } | DevflareConfigInput>>( + nameOrImport: string | TImport, + maybeImport?: TImport +): RefResult> { + type TConfig = ExtractConfig + const nameOverride = typeof nameOrImport === 'string' ? nameOrImport : undefined + let importFn: ConfigImport | undefined + + if (typeof nameOrImport === 'function') { + importFn = nameOrImport as unknown as ConfigImport + } else if (typeof maybeImport === 'function') { + importFn = maybeImport as unknown as ConfigImport + } + + if (!importFn) { + throw new Error('ref() requires an import function') + } + + const resolvedImportFn = importFn + + // Extract the import path from the function's source code. + // + // Ideal approach: runtime probe via Proxy (call `fn(rootProxy)` and observe + // the property chains the proxy was accessed on). That approach doesn't apply + // here because the input is a dynamic `import()` expression โ€” a syntactic + // operator that cannot be intercepted by replacing globals or parameters. + // + // We therefore parse the function source with a narrow regex, then guard the + // result against obviously-minified or otherwise-unparseable inputs so we + // fail loudly instead of silently returning a bogus path. + const configPath = extractConfigPathFromImportFn(resolvedImportFn) + const doBindingCache = new Map() + + // Helper to resolve the config + async function doResolve(): Promise> { + // Check cache first + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) return cached + + // Check if resolution is in progress + const pending = pendingResolutions.get(proxy) + if (pending) return pending as Promise> + + // Start resolution + const promise = (async () => { + const module = await resolvedImportFn() + const config = ('default' in module ? module.default : module) as TConfig + + if (!config.name && !nameOverride) { + throw new Error('Referenced config must have a "name" property') + } + + const resolved: ResolvedData = { + name: nameOverride ?? config.name, + config, + configPath + } + + resolvedCache.set(proxy, resolved) + return resolved + })() + + const trackedPromise = promise.finally(() => { + pendingResolutions.delete(proxy) + }) as Promise> + + pendingResolutions.set(proxy, trackedPromise as Promise) + return trackedPromise + } + + // Helper to get resolved value synchronously (throws if not resolved) + function getResolved(): ResolvedData { + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) return cached + throw new Error( + 'ref() not yet resolved. Call ref().resolve() first, or use top-level await ' + + 'in your config file to resolve all refs before exporting.' + ) + } + + // Create worker binding (deferred - doesn't need resolution immediately) + function createWorkerBinding(entrypoint?: string): WorkerBinding { + return { + // Service name is deferred - will be resolved when config is loaded + get service() { + // Try to get from cache, but don't throw if not resolved yet + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) return cached.name + // If name override is provided, use it directly + if (nameOverride) return nameOverride + // Otherwise, indicate pending (this will be resolved by test context) + return PENDING_REF_VALUE + }, + entrypoint, + __ref: proxy + } + } + + // Worker accessor using a Proxy to defer property access + const workerAccessor = new Proxy( + (entrypoint: string) => createWorkerBinding(entrypoint), + { + get(target, prop) { + if (prop === 'service') { + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) return cached.name + if (nameOverride) return nameOverride + return PENDING_REF_VALUE + } + if (prop === 'entrypoint') return undefined + if (prop === '__ref') return proxy + return Reflect.get(target, prop) + } + } + ) as WorkerBindingAccessor + + // Create DO binding for cross-worker access + function createDOBinding(bindingName: string): DOBindingRef { + const cachedBinding = doBindingCache.get(bindingName) + if (cachedBinding) { + return cachedBinding + } + + const doBinding: DOBindingRef = { + // className is a getter that resolves lazily from the config + get className() { + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached?.config.bindings?.durableObjects) { + const doBindings = cached.config.bindings.durableObjects as Record + const doConfig = doBindings[bindingName] + if (typeof doConfig === 'string') { + return doConfig + } else if (doConfig && typeof doConfig === 'object' && 'className' in doConfig) { + return (doConfig as { className: string }).className + } + } + return PENDING_REF_VALUE + }, + get scriptName() { + // Worker name for cross-worker access + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) return cached.name + if (nameOverride) return nameOverride + return PENDING_REF_VALUE + }, + kind: 'cross-worker', + __ref: proxy + } + + doBindingCache.set(bindingName, doBinding) + return doBinding + } + + // Known properties on RefResult (not DO bindings) + const knownProps = new Set(['name', 'config', 'configPath', 'worker', '__import', '__nameOverride', 'resolve', 'then']) + + // Create the proxy object with dynamic DO binding support + const proxyTarget = { + get name() { return getResolved().name }, + get config() { return getResolved().config }, + configPath, + worker: workerAccessor, + __import: resolvedImportFn, + __nameOverride: nameOverride, + resolve: doResolve + } + + const proxy = new Proxy(proxyTarget, { + get(target, prop) { + // Handle known properties + if (typeof prop === 'string' && knownProps.has(prop)) { + return Reflect.get(target, prop) + } + + // Handle symbol properties (like Symbol.toStringTag) + if (typeof prop === 'symbol') { + return Reflect.get(target, prop) + } + + // Dynamic DO binding access: ref.COUNTER, ref.RATE_LIMITER, etc. + // Property names that are UPPER_CASE are treated as DO bindings, + // but only when the resolved config actually declares the binding. + // Once resolved, accessing an unknown UPPER_CASE prop returns + // undefined instead of fabricating a DOBindingRef whose getters + // would just return PENDING_REF_VALUE forever. Pre-resolution we + // fall back to the lazy ref so consumers that grab refs eagerly + // (e.g. at module top level) keep working. + if (typeof prop === 'string' && /^[A-Z][A-Z0-9_]*$/.test(prop)) { + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) { + const doBindings = cached.config.bindings?.durableObjects as + | Record + | undefined + if (!doBindings || !(prop in doBindings)) { + return undefined + } + } + return createDOBinding(prop) + } + + return Reflect.get(target, prop) + }, + has(target, prop) { + // Known props + any UPPER_CASE prop that is actually declared as a + // DO binding in the resolved config (or any UPPER_CASE prop pre- + // resolution, mirroring the lenient `get` behaviour). + if (typeof prop === 'string') { + if (knownProps.has(prop)) return true + if (/^[A-Z][A-Z0-9_]*$/.test(prop)) { + const cached = resolvedCache.get(proxy) as ResolvedData | undefined + if (cached) { + const doBindings = cached.config.bindings?.durableObjects as + | Record + | undefined + return !!doBindings && prop in doBindings + } + return true + } + } + return Reflect.has(target, prop) + } + }) as unknown as RefResult + + return proxy +} diff --git a/packages/devflare/src/config/resolve-phased.ts b/packages/devflare/src/config/resolve-phased.ts new file mode 100644 index 0000000..1a5d137 --- /dev/null +++ b/packages/devflare/src/config/resolve-phased.ts @@ -0,0 +1,161 @@ +// ============================================================================= +// Phase-discriminated resolver (R1 step 1 - facade + branded phase types) +// ============================================================================= +// This module exposes a single `resolveResources({ phase })` entry point that +// unifies the three lifecycle consumers documented in +// `tests/unit/config/resolver-contract.test.ts`: +// +// * build - preserves name-based KV/D1/Hyperdrive bindings (no network) +// * local - materializes name-based bindings into stable local identifiers +// * deploy - resolves or provisions concrete Cloudflare IDs +// +// Today this is a facade - it delegates to the existing helpers without +// changing behavior, so the resolver-contract regression gate stays green. +// Subsequent R1 steps collapse the duplicated internals and brand the return +// type per phase so that `compileConfig` can refuse an unresolved config at +// the type level. +// +// See `.local/refactors/R1-phase-discriminated-resolver.md` for the plan. +// ============================================================================= + +import { +prepareMaterializedConfigResourcesForDeploy, +type PrepareMaterializedConfigResourcesForDeployOptions +} from './deploy-resources' +import { materializePreviewScopedConfig, type PreviewResolutionOptions } from './preview' +import { mergeConfigForEnvironment } from './resolve' +import { +resolveConfigForLocalRuntime, +resolveMaterializedConfigResources, +type ResolveMaterializedConfigResourcesOptions +} from './resource-resolution' +import type { DevflareConfig } from './schema' + +declare const __phaseBrand: unique symbol + +/** + * `DevflareConfig` after the build-phase pipeline. KV/D1/Hyperdrive bindings + * may still carry `{ name }`-only entries because the build artefact is + * reproducible offline and resolves IDs at deploy. + */ +export type BuildConfig = DevflareConfig & { readonly [__phaseBrand]: 'build' } + +/** + * `DevflareConfig` after the local-phase pipeline. KV/D1/Hyperdrive bindings + * have been materialized into stable local identifiers via + * `getLocalXIdentifier()` helpers. + */ +export type LocalConfig = DevflareConfig & { readonly [__phaseBrand]: 'local' } + +/** + * `DevflareConfig` after the deploy-phase pipeline. KV/D1/Hyperdrive bindings + * have been resolved (and optionally provisioned) against a live Cloudflare + * account. + */ +export type DeployConfig = DevflareConfig & { readonly [__phaseBrand]: 'deploy' } + +export type Phase = 'build' | 'local' | 'deploy' + +/** + * Configurations whose KV/D1/Hyperdrive bindings are guaranteed to carry an + * `id` field (either a real Cloudflare resource ID or a stable local + * identifier). `compileConfig()` requires this brand so that passing a raw + * `DevflareConfig` is rejected at compile time rather than runtime. + */ +export type ResolvedConfig = LocalConfig | DeployConfig + +/** + * Cast helper used at the resolve-phase boundaries. The brand is a phantom + * intersection so this is a zero-cost reinterpretation. + */ +export function brandAsLocalConfig(config: DevflareConfig): LocalConfig { + return config as LocalConfig +} + +/** + * Cast helper used at the resolve-phase boundaries. The brand is a phantom + * intersection so this is a zero-cost reinterpretation. + */ +export function brandAsDeployConfig(config: DevflareConfig): DeployConfig { + return config as DeployConfig +} + +export type PhaseConfig

= +P extends 'build' ? BuildConfig +: P extends 'local' ? LocalConfig +: P extends 'deploy' ? DeployConfig +: never + +export interface ResolveResourcesCommonOptions { +environment?: string +preview?: PreviewResolutionOptions +} + +export interface ResolveResourcesBuildOptions extends ResolveResourcesCommonOptions { +phase: 'build' +} + +export interface ResolveResourcesLocalOptions extends ResolveResourcesCommonOptions { +phase: 'local' +} + +export interface ResolveResourcesDeployOptions +extends ResolveResourcesCommonOptions, +ResolveMaterializedConfigResourcesOptions { +phase: 'deploy' +provision?: boolean +preparation?: PrepareMaterializedConfigResourcesForDeployOptions +} + +export type ResolveResourcesOptions = +| ResolveResourcesBuildOptions +| ResolveResourcesLocalOptions +| ResolveResourcesDeployOptions + +/** + * Unified phase-discriminated resource resolver. Facade over the legacy + * per-phase helpers; stamps the appropriate phase brand on the returned + * config so callers can narrow at the type layer. + */ +export async function resolveResources( +config: DevflareConfig, +options: O +): Promise> { +const envMerged = mergeConfigForEnvironment(config, options.environment) +// C2 prep: always materialize preview-scoped values so this seam is a strict +// superset of the legacy per-phase entry points (`resolveConfigForEnvironment`, +// `resolveConfigForLocalRuntime`, `resolveConfigResources`), which all +// materialize preview unconditionally. Callers can still pass extra +// `preview` resolution options (env / identifier overrides). +const previewMerged = materializePreviewScopedConfig(envMerged, { +environment: options.environment, +...options.preview +}) + +switch (options.phase) { +case 'build': { +return previewMerged as PhaseConfig +} +case 'local': { +const resolved = resolveConfigForLocalRuntime(previewMerged, undefined) +return resolved as PhaseConfig +} +case 'deploy': { +if (options.provision) { +const prepared = await prepareMaterializedConfigResourcesForDeploy( +previewMerged, +options.preparation ?? { +accountId: options.accountId, +cloudflare: options.cloudflare +} +) +return prepared.config as PhaseConfig +} +const materialized = await resolveMaterializedConfigResources(previewMerged, { +accountId: options.accountId, +cloudflare: options.cloudflare +}) +return materialized as PhaseConfig +} +} +} \ No newline at end of file diff --git a/packages/devflare/src/config/resolve.ts b/packages/devflare/src/config/resolve.ts new file mode 100644 index 0000000..af3a521 --- /dev/null +++ b/packages/devflare/src/config/resolve.ts @@ -0,0 +1,75 @@ +import { normalizeCompatibilityFlags } from './compatibility' +import { materializePreviewScopedConfig } from './preview' +import type { DevflareConfig } from './schema' + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function mergeEnvironmentValue(base: unknown, override: unknown): unknown { + if (override === undefined) { + return base + } + + if (Array.isArray(override)) { + return [...override] + } + + if (isPlainObject(override)) { + const baseObject = isPlainObject(base) ? base : {} + const mergedObject: Record = { + ...baseObject + } + + for (const [key, value] of Object.entries(override)) { + mergedObject[key] = mergeEnvironmentValue(baseObject[key], value) + } + + return mergedObject + } + + return override +} + +function withNormalizedCompatibilityFlags(config: DevflareConfig): DevflareConfig { + return { + ...config, + compatibilityFlags: normalizeCompatibilityFlags(config.compatibilityFlags) + } +} + +export function mergeConfigForEnvironment( + config: DevflareConfig, + environment?: string +): DevflareConfig { + if (!environment || !config.env?.[environment]) { + return withNormalizedCompatibilityFlags(config) + } + + const mergedConfig = mergeEnvironmentValue(config, config.env[environment]) as DevflareConfig + + return withNormalizedCompatibilityFlags(mergedConfig) +} + +/** + * @internal Prefer the unified `resolveResources(config, { phase: 'build' })` + * facade for the env-merge + preview-materialize pipeline. This lower-level + * helper remains exported as the implementation that the seam and the + * build-time compiler delegate to; it is not part of the recommended public + * surface. + */ +export function resolveConfigForEnvironment( + config: DevflareConfig, + environment?: string +): DevflareConfig { + const mergedConfig = mergeConfigForEnvironment(config, environment) + + return materializePreviewScopedConfig(mergedConfig, { + environment + }) +} diff --git a/packages/devflare/src/config/resource-resolution.ts b/packages/devflare/src/config/resource-resolution.ts new file mode 100644 index 0000000..8df5218 --- /dev/null +++ b/packages/devflare/src/config/resource-resolution.ts @@ -0,0 +1,297 @@ +import { getPrimaryAccount, listD1Databases, listHyperdrives, listKVNamespaces } from '../cloudflare/account' +import { getEffectiveAccountId } from '../cloudflare/preferences' +import { + collectPendingNameBindings, + formatMissingBindings, + materializeHyperdriveIdBindings, + materializeIdBindings, + materializeResolvedNameBindings, + normalizeD1NameBinding, + normalizeHyperdriveNameBinding, + normalizeKVNameBinding, + withResolvedIdBindings, + type PendingNameBinding +} from './binding-resolution-helpers' +import { loadConfig, type LoadConfigOptions } from './loader' +import { type PreviewResolutionOptions } from './preview' +import { brandAsDeployConfig, brandAsLocalConfig, resolveResources, type DeployConfig, type LocalConfig } from './resolve-phased' +import { resolveConfigForEnvironment } from './resolve' +import { + getLocalD1DatabaseIdentifier, + getLocalKVNamespaceIdentifier, + type DevflareConfig +} from './schema' + +interface CloudflareConfigResolutionApi { + getPrimaryAccount: typeof getPrimaryAccount + getEffectiveAccountId: typeof getEffectiveAccountId + listKVNamespaces: typeof listKVNamespaces + listD1Databases: typeof listD1Databases + listHyperdrives: typeof listHyperdrives +} + +const defaultCloudflareApi: CloudflareConfigResolutionApi = { + getPrimaryAccount, + getEffectiveAccountId, + listKVNamespaces, + listD1Databases, + listHyperdrives +} + +export interface ResolveConfigResourcesOptions { + environment?: string + env?: PreviewResolutionOptions['env'] + identifier?: string + accountId?: string + cloudflare?: Partial +} + +export interface ResolveMaterializedConfigResourcesOptions { + accountId?: string + cloudflare?: Partial +} + +export interface LoadResolvedConfigOptions extends LoadConfigOptions { + env?: PreviewResolutionOptions['env'] + identifier?: string + accountId?: string + cloudflare?: Partial +} + +export class ConfigResourceResolutionError extends Error { + readonly code = 'CONFIG_RESOURCE_RESOLUTION_ERROR' + + constructor(message: string, cause?: unknown) { + super(message) + this.name = 'ConfigResourceResolutionError' + if (cause !== undefined) { + ; (this as Error & { cause?: unknown }).cause = cause + } + } +} + +function resolveCloudflareApi( + overrides: Partial | undefined +): CloudflareConfigResolutionApi { + return { + ...defaultCloudflareApi, + ...(overrides ?? {}) + } +} + +async function resolveLookupAccountId( + config: DevflareConfig, + options: ResolveConfigResourcesOptions, + cloudflareApi: CloudflareConfigResolutionApi +): Promise { + const explicitAccountId = options.accountId ?? config.accountId + if (explicitAccountId) { + return explicitAccountId + } + + let primaryAccount + try { + primaryAccount = await cloudflareApi.getPrimaryAccount() + } catch (error) { + throw new ConfigResourceResolutionError( + 'Could not resolve Cloudflare-backed resource names because Devflare could not read your Cloudflare accounts. Set accountId in devflare.config.ts, configure a workspace/global default account, or log in with Wrangler.', + error + ) + } + + if (!primaryAccount) { + throw new ConfigResourceResolutionError( + 'Could not resolve Cloudflare-backed resource names because no Cloudflare account is available. Set accountId in devflare.config.ts, configure a workspace/global default account, or log in with Wrangler.' + ) + } + + try { + const { accountId } = await cloudflareApi.getEffectiveAccountId(primaryAccount.id) + return accountId + } catch (error) { + throw new ConfigResourceResolutionError( + `Could not determine the effective Cloudflare account for name-based resource resolution after selecting primary account ${primaryAccount.id}.`, + error + ) + } +} + +async function resolveResourceIdsByName( + pendingBindings: PendingNameBinding[], + options: { + listResources: () => Promise + listFailureMessage: string + missingFailureMessage: (missing: PendingNameBinding[]) => string + } +): Promise> { + if (pendingBindings.length === 0) { + return new Map() + } + + let resources: TResource[] + try { + resources = await options.listResources() + } catch (error) { + throw new ConfigResourceResolutionError(options.listFailureMessage, error) + } + + const idsByName = new Map( + resources.map((resource) => [resource.name, resource.id]) + ) + + const missingBindings = pendingBindings.filter(({ resourceName }) => { + return !idsByName.has(resourceName) + }) + + if (missingBindings.length > 0) { + throw new ConfigResourceResolutionError(options.missingFailureMessage(missingBindings)) + } + + return idsByName +} + +/** + * Resolve environment overrides and normalize KV/D1/Hyperdrive bindings for purely local runtimes. + * + * Local Miniflare/workerd flows can use either an explicit resource ID or the + * stable resource name as the backing identifier, so this path avoids requiring + * Cloudflare auth for local development and tests. + * + * @internal Prefer the unified `resolveResources(config, { phase: 'local' })` + * facade. This lower-level helper remains exported for backwards compatibility + * and as the implementation that the seam delegates to; it is not part of the + * recommended public surface and may be removed in a future major release. + */ +export function resolveConfigForLocalRuntime( + config: DevflareConfig, + environment?: string +): LocalConfig { + const resolvedConfig = resolveConfigForEnvironment(config, environment) + const kvBindings = resolvedConfig.bindings?.kv + const d1Bindings = resolvedConfig.bindings?.d1 + const hyperdriveBindings = resolvedConfig.bindings?.hyperdrive + + if (!kvBindings && !d1Bindings && !hyperdriveBindings) { + return brandAsLocalConfig(resolvedConfig) + } + const pendingKVNameBindings = collectPendingNameBindings(kvBindings, normalizeKVNameBinding) + return brandAsLocalConfig(withResolvedIdBindings(resolvedConfig, { + kv: kvBindings ? materializeIdBindings(kvBindings, getLocalKVNamespaceIdentifier) : undefined, + d1: d1Bindings ? materializeIdBindings(d1Bindings, getLocalD1DatabaseIdentifier) : undefined, + hyperdrive: materializeHyperdriveIdBindings(hyperdriveBindings) + })) +} + +/** + * Resolve Cloudflare-backed resource references such as KV/D1/Hyperdrive + * name bindings into concrete IDs. + * + * Used by the deploy path and by automation/programmatic consumers that need + * fully-resolved bindings against a live Cloudflare account. The build path + * intentionally does NOT call this โ€” `compileBuildConfig({ preserveNamedBindings: true })` + * keeps name-only bindings symbolic in the build artifact so builds remain + * reproducible offline. Pick this helper only when ID resolution is desired. + * + * @internal Prefer the unified `resolveResources(config, { phase: 'deploy' })` + * facade. This lower-level helper remains exported as the implementation the + * seam delegates to; it is not part of the recommended public surface. + */ +export async function resolveMaterializedConfigResources( + resolvedConfig: DevflareConfig, + options: ResolveMaterializedConfigResourcesOptions = {} +): Promise { + const kvBindings = resolvedConfig.bindings?.kv + const d1Bindings = resolvedConfig.bindings?.d1 + const hyperdriveBindings = resolvedConfig.bindings?.hyperdrive + + if (!kvBindings && !d1Bindings && !hyperdriveBindings) { + return brandAsDeployConfig(resolvedConfig) + } + + const pendingKVNameBindings = collectPendingNameBindings(kvBindings, normalizeKVNameBinding) + const pendingD1NameBindings = collectPendingNameBindings(d1Bindings, normalizeD1NameBinding) + const pendingHyperdriveNameBindings = collectPendingNameBindings(hyperdriveBindings, normalizeHyperdriveNameBinding) + + if ( + pendingKVNameBindings.length === 0 + && pendingD1NameBindings.length === 0 + && pendingHyperdriveNameBindings.length === 0 + ) { + return brandAsDeployConfig(withResolvedIdBindings(resolvedConfig, { + kv: kvBindings ? materializeIdBindings(kvBindings, getLocalKVNamespaceIdentifier) : undefined, + d1: d1Bindings ? materializeIdBindings(d1Bindings, getLocalD1DatabaseIdentifier) : undefined, + hyperdrive: materializeHyperdriveIdBindings(hyperdriveBindings) + })) + } + + const cloudflareApi = resolveCloudflareApi(options.cloudflare) + const accountId = await resolveLookupAccountId(resolvedConfig, options, cloudflareApi) + + const namespaceIdsByName = await resolveResourceIdsByName(pendingKVNameBindings, { + listResources: async () => cloudflareApi.listKVNamespaces(accountId), + listFailureMessage: `Could not list KV namespaces for Cloudflare account ${accountId} while resolving name-based KV bindings.`, + missingFailureMessage: (missingBindings) => { + return `Could not find KV namespace(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}.` + } + }) + + const databaseIdsByName = await resolveResourceIdsByName(pendingD1NameBindings, { + listResources: async () => cloudflareApi.listD1Databases(accountId), + listFailureMessage: `Could not list D1 databases for Cloudflare account ${accountId} while resolving name-based D1 bindings.`, + missingFailureMessage: (missingBindings) => { + return `Could not find D1 database(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}.` + } + }) + + const hyperdriveIdsByName = await resolveResourceIdsByName(pendingHyperdriveNameBindings, { + listResources: async () => cloudflareApi.listHyperdrives(accountId), + listFailureMessage: `Could not list Hyperdrive configurations for Cloudflare account ${accountId} while resolving name-based Hyperdrive bindings.`, + missingFailureMessage: (missingBindings) => { + return `Could not find Hyperdrive configuration(s) for ${formatMissingBindings(missingBindings)} in Cloudflare account ${accountId}.` + } + }) + + return brandAsDeployConfig(withResolvedIdBindings(resolvedConfig, { + kv: materializeResolvedNameBindings(kvBindings, normalizeKVNameBinding, namespaceIdsByName), + d1: materializeResolvedNameBindings(d1Bindings, normalizeD1NameBinding, databaseIdsByName), + hyperdrive: materializeHyperdriveIdBindings(hyperdriveBindings, hyperdriveIdsByName) + })) +} + +/** + * Resolve Cloudflare-backed resource references such as KV/D1/Hyperdrive name bindings into + * concrete IDs for build, deploy, and automation workflows. + * + * @internal Prefer the unified `resolveResources(config, { phase: 'deploy' })` + * facade. This wrapper is now a thin shim over the seam, retained for + * backwards compatibility with existing callers (`loadResolvedConfig`, etc.). + */ +export async function resolveConfigResources( + config: DevflareConfig, + options: ResolveConfigResourcesOptions = {} +): Promise { + return resolveResources(config, { + phase: 'deploy', + environment: options.environment, + preview: { + environment: options.environment, + env: options.env, + identifier: options.identifier + }, + accountId: options.accountId, + cloudflare: options.cloudflare + }) +} + +/** + * Load devflare.config.* and resolve any Cloudflare-backed resource references. + * + * This is the public Node-side API for external automation that needs the same + * resolved values Devflare build/deploy flows use. + */ +export async function loadResolvedConfig( + options: LoadResolvedConfigOptions = {} +): Promise { + const config = await loadConfig(options) + return resolveConfigResources(config, options) +} diff --git a/packages/devflare/src/config/schema-bindings.ts b/packages/devflare/src/config/schema-bindings.ts new file mode 100644 index 0000000..24c1e2a --- /dev/null +++ b/packages/devflare/src/config/schema-bindings.ts @@ -0,0 +1,639 @@ +import { z } from 'zod' + +/** + * Durable Object binding input type. + * Accepts both string shorthand and object form (including DOBindingRef from ref()). + */ +export type DurableObjectBindingInput = + | string + | { + /** The Durable Object class name */ + readonly className: string + /** + * Script name for cross-worker DO access. + * For local DOs: file path (e.g., 'do.counter.ts') + * For cross-worker DOs: worker name (e.g., 'do-service') + */ + readonly scriptName?: string + /** @internal Reference marker for cross-worker DO bindings */ + readonly __ref?: unknown + } + +/** + * Durable Object binding schema. + * Validates DO binding configuration in either string or object form. + */ +export const durableObjectBindingSchema = z.custom((val) => { + if (typeof val === 'string') { + return true + } + + if (val && typeof val === 'object' && 'className' in val) { + const obj = val as Record + return typeof obj.className === 'string' + } + + return false +}, { + message: 'Expected string or { className: string, scriptName?: string }' +}) + +/** + * Queue consumer configuration. + * Defines how messages are consumed from a Cloudflare Queue. + */ +export const queueConsumerSchema = z.object({ + /** Queue name to consume from */ + queue: z.string(), + /** + * Maximum messages per batch (1-100). + * @default 10 + */ + maxBatchSize: z.number().optional(), + /** + * Maximum seconds to wait for a full batch. + * @default 5 + */ + maxBatchTimeout: z.number().optional(), + /** + * Maximum retry attempts for failed messages. + * @default 3 + */ + maxRetries: z.number().optional(), + /** Queue name to send failed messages after max retries */ + deadLetterQueue: z.string().optional(), + /** Maximum concurrent batch invocations */ + maxConcurrency: z.number().optional(), + /** Delay in seconds between retries */ + retryDelay: z.number().optional() +}) + +/** + * Queues configuration for producers and consumers. + */ +export const queuesConfigSchema = z.object({ + /** + * Queue producer bindings. + * Maps binding name to queue name. + * @example { TASK_QUEUE: 'task-queue' } + */ + producers: z.record(z.string(), z.string()).optional(), + /** + * Queue consumer configurations. + * Array of consumer configs for processing queue messages. + */ + consumers: z.array(queueConsumerSchema).optional() +}) + +/** + * Rate Limiting binding configuration. + * Devflare uses camelCase authoring and compiles to Wrangler's `ratelimits` + * array (`namespace_id`, `simple.limit`, `simple.period`). + */ +export const rateLimitBindingSchema = z.object({ + /** Positive integer string unique to the Cloudflare account */ + namespaceId: z.string().regex(/^[1-9]\d*$/, 'namespaceId must be a positive integer string'), + /** Simple rate limiting is the only currently supported Cloudflare mode */ + simple: z.object({ + /** Number of allowed calls within the configured period */ + limit: z.number().int().positive(), + /** Rate limit window in seconds */ + period: z.union([z.literal(10), z.literal(60)]) + }).strict() +}).strict() + +/** + * Version Metadata binding configuration. + */ +export const versionMetadataBindingSchema = z.object({ + /** Binding name exposed in env (for example, CF_VERSION_METADATA) */ + binding: z.string().min(1) +}).strict() + +/** + * Worker Loader binding configuration for Dynamic Workers. + */ +export const workerLoaderBindingSchema = z.object({}).strict() + +/** + * Secrets Store binding configuration. + * Devflare accepts object form for explicit per-binding store IDs and string + * shorthand when the worker sets a top-level `secretsStoreId`. + */ +export const secretsStoreBindingSchema = z.union([ + z.string().min(1), + z.object({ + /** Secrets Store ID containing the account-level secret */ + storeId: z.string().min(1), + /** Secret name within the store */ + secretName: z.string().min(1) + }).strict() +]) + +/** + * Service binding schema. + * Binds to another Worker for RPC-style communication. + * Accepts plain objects or WorkerBinding from ref().worker. + */ +const serviceBindingKeys = new Set(['service', 'environment', 'entrypoint', '__ref']) + +function isServiceBindingValue(val: unknown): boolean { + if ((typeof val !== 'object' && typeof val !== 'function') || val === null) { + return false + } + + const obj = val as Record + if (typeof obj.service !== 'string' || obj.service.trim().length === 0) { + return false + } + + if (obj.environment !== undefined && (typeof obj.environment !== 'string' || obj.environment.trim().length === 0)) { + return false + } + + if (obj.entrypoint !== undefined && (typeof obj.entrypoint !== 'string' || obj.entrypoint.trim().length === 0)) { + return false + } + + if (typeof val === 'object') { + for (const key of Object.keys(obj)) { + if (!serviceBindingKeys.has(key)) { + return false + } + } + } + + return true +} + +export const serviceBindingSchema = z.custom<{ + /** Target worker/service name */ + service: string + /** Optional environment (staging, production, etc.) */ + environment?: string + /** Optional entrypoint class name for named exports */ + entrypoint?: string + /** @internal Reference marker for ref() bindings */ + __ref?: unknown +}>(isServiceBindingValue, { + message: 'Expected service binding object with { service: string, environment?: string, entrypoint?: string } or ref().worker' +}) + +/** + * AI binding configuration. + * Provides access to Cloudflare Workers AI for inference. + */ +export const aiBindingSchema = z.object({ + /** Binding name exposed in env (e.g., 'AI') */ + binding: z.string(), + /** Ask Wrangler local development to connect this binding to the remote Workers AI service */ + remote: z.boolean().optional(), + /** Use Cloudflare's staging Workers AI environment for this binding */ + staging: z.boolean().optional() +}).strict() + +/** + * AI Search namespace binding configuration. + * Provides access to all AI Search instances in a namespace. + */ +export const aiSearchNamespaceBindingSchema = z.object({ + /** AI Search namespace name */ + namespace: z.string().min(1), + /** Ask Wrangler local development to connect this binding remotely */ + remote: z.boolean().optional() +}).strict() + +/** + * AI Search instance binding configuration. + * Provides direct access to one AI Search instance in the default namespace. + */ +export const aiSearchInstanceBindingSchema = z.object({ + /** AI Search instance name */ + instanceName: z.string().min(1), + /** Ask Wrangler local development to connect this binding remotely */ + remote: z.boolean().optional() +}).strict() + +/** + * Vectorize index binding configuration. + * Provides access to a Cloudflare Vectorize index for similarity search. + */ +export const vectorizeBindingSchema = z.object({ + /** Name of the Vectorize index */ + indexName: z.string(), + /** Ask Wrangler local development to connect this binding to the remote index */ + remote: z.boolean().optional() +}) + +/** + * Hyperdrive binding configuration. + * Provides accelerated PostgreSQL connections via connection pooling. + */ +export const hyperdriveBindingByIdSchema = z.object({ + /** Explicit Hyperdrive configuration ID */ + id: z.string(), + /** Direct database connection string used by local Miniflare/Wrangler dev */ + localConnectionString: z.string().optional() +}).strict() + +export const hyperdriveBindingByNameSchema = z.object({ + /** Stable Hyperdrive configuration name to resolve to an ID at config/build/deploy time */ + name: z.string(), + /** Direct database connection string used by local Miniflare/Wrangler dev */ + localConnectionString: z.string().optional(), + /** + * Opt-in fallback behavior for preview-scoped Hyperdrive bindings. + * When set to `'base'`, Devflare is permitted to reuse the base Hyperdrive + * configuration if no dedicated preview Hyperdrive exists in the account. + * When omitted, missing preview Hyperdrives cause a config-resolution error. + */ + previewFallback: z.literal('base').optional(), + /** Explicit dedicated preview Hyperdrive configuration ID */ + previewId: z.string().optional(), + /** Legacy alias for a preview/dev local connection string; prefer localConnectionString */ + previewLocalConnectionString: z.string().optional() +}).strict() + +export const hyperdriveBindingSchema = z.union([ + z.string(), + hyperdriveBindingByIdSchema, + hyperdriveBindingByNameSchema +]) + +const SINGLE_BROWSER_BINDING_ERROR_MESSAGE = 'Devflare currently supports exactly one browser binding because Wrangler only supports a single browser binding.' + +export function formatBrowserBindingLimitMessage(bindingNames: string[]): string { + if (bindingNames.length <= 1) { + return SINGLE_BROWSER_BINDING_ERROR_MESSAGE + } + + return `${SINGLE_BROWSER_BINDING_ERROR_MESSAGE} Configured bindings: ${bindingNames.join(', ')}` +} + +export function getBrowserBindingNames(bindings: Record | undefined): string[] { + return bindings ? Object.keys(bindings) : [] +} + +/** + * Browser Rendering binding configuration. + * Provides headless browser access for rendering/screenshots. + */ +export const browserBindingValueSchema = z.union([ + z.string(), + z.object({ + /** Ask Wrangler local development to connect this binding to the remote Browser Rendering service */ + remote: z.boolean().optional() + }).strict() +]) + +export const browserBindingSchema = z.record(z.string(), browserBindingValueSchema).superRefine((bindings, ctx) => { + const bindingNames = getBrowserBindingNames(bindings) + if (bindingNames.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: formatBrowserBindingLimitMessage(bindingNames) + }) + } +}) + +/** + * Analytics Engine binding configuration. + * Provides access to Cloudflare Analytics Engine for event logging. + */ +export const analyticsBindingSchema = z.object({ + /** Analytics Engine dataset name */ + dataset: z.string() +}) + +/** + * Email sending binding configuration. + * Enables sending emails via Cloudflare Email Routing. + */ +export const sendEmailBindingSchema = z.object({ + /** Restrict this binding to a specific verified destination address */ + destinationAddress: z.string().optional(), + /** Restrict this binding to a set of verified destination addresses */ + allowedDestinationAddresses: z.array(z.string()).optional(), + /** Restrict this binding to a set of verified sender addresses */ + allowedSenderAddresses: z.array(z.string()).optional() +}).refine((binding) => { + return !(binding.destinationAddress && binding.allowedDestinationAddresses) +}, { + message: 'sendEmail bindings must use either destinationAddress or allowedDestinationAddresses, not both', + path: ['allowedDestinationAddresses'] +}) + +export const d1BindingByIdSchema = z.object({ + /** Explicit D1 database ID */ + id: z.string() +}).strict() + +export const d1BindingByNameSchema = z.object({ + /** Stable D1 database name to resolve to an ID at config/build/deploy time */ + name: z.string() +}).strict() + +export const d1BindingSchema = z.union([ + z.string(), + d1BindingByIdSchema, + d1BindingByNameSchema +]) + +export const kvBindingByIdSchema = z.object({ + /** Explicit KV namespace ID */ + id: z.string() +}).strict() + +export const kvBindingByNameSchema = z.object({ + /** Stable KV namespace name to resolve to an ID at config/build/deploy time */ + name: z.string() +}).strict() + +export const kvBindingSchema = z.union([ + z.string(), + kvBindingByIdSchema, + kvBindingByNameSchema +]) + +export const mtlsCertificateBindingByIdSchema = z.object({ + /** Uploaded mTLS certificate UUID from `wrangler mtls-certificate upload` */ + certificateId: z.string().min(1), + /** Ask Wrangler local development to use the remote binding when available */ + remote: z.boolean().optional() +}).strict() + +export const mtlsCertificateBindingByWranglerIdSchema = z.object({ + /** Wrangler-native uploaded mTLS certificate UUID */ + certificate_id: z.string().min(1), + /** Ask Wrangler local development to use the remote binding when available */ + remote: z.boolean().optional() +}).strict() + +/** + * C17 โ€” mTLS Certificate binding. + * The id is the UUID returned by `wrangler mtls-certificate upload`. + */ +export const mtlsCertificateBindingSchema = z.union([ + z.string().min(1), + mtlsCertificateBindingByIdSchema, + mtlsCertificateBindingByWranglerIdSchema +]) + +/** + * C17 โ€” Workers for Platforms (Dispatch Namespace) binding. + */ +export const dispatchNamespaceBindingSchema = z.union([ + z.string().min(1), + z.object({ + namespace: z.string().min(1), + outbound: z.object({ + service: z.string().min(1), + environment: z.string().optional(), + parameters: z.array(z.string()).optional() + }).strict().optional(), + remote: z.boolean().optional() + }).strict() +]) + +/** + * C17 โ€” Workflows-as-binding (a workflow class exposed for another worker + * to invoke). Distinct from a worker declaring its own workflows. + */ +export const workflowBindingSchema = z.object({ + name: z.string().min(1), + className: z.string().min(1), + scriptName: z.string().min(1).optional(), + remote: z.boolean().optional(), + limits: z.object({ + steps: z.number().int().positive() + }).strict().optional() +}).strict() + +/** + * C17 โ€” Cloudflare Pipelines binding. + */ +export const pipelineBindingSchema = z.union([ + z.string().min(1), + z.object({ + pipeline: z.string().min(1), + remote: z.boolean().optional() + }).strict() +]) + +/** + * C17 โ€” Cloudflare Images binding (transformation/upload service). + */ +export const imagesBindingSchema = z.object({ + remote: z.boolean().optional() +}).strict().or(z.literal(true)) + +/** + * C17 โ€” Cloudflare Media Transformations binding. + */ +export const mediaBindingSchema = z.object({ + remote: z.boolean().optional() +}).strict().or(z.literal(true)) + +/** + * C17 โ€” Cloudflare Artifacts binding. + */ +export const artifactsBindingSchema = z.union([ + z.string().min(1), + z.object({ + namespace: z.string().min(1), + remote: z.boolean().optional() + }).strict() +]) + +/** + * All worker bindings configuration. + * Defines connections to Cloudflare services and resources. + */ +export const bindingsSchema = z.object({ + /** + * KV Namespace bindings. + * Maps binding name to either a stable KV namespace name or an explicit resolver object. + */ + kv: z.record(z.string(), kvBindingSchema).optional(), + + /** + * D1 Database bindings. + * Maps binding name to either a stable D1 database name or an explicit resolver object. + */ + d1: z.record(z.string(), d1BindingSchema).optional(), + + /** + * R2 Bucket bindings. + * Maps binding name to R2 bucket name. + */ + r2: z.record(z.string(), z.string()).optional(), + + /** + * Durable Object bindings. + * Maps binding name to DO class configuration. + */ + durableObjects: z.record(z.string(), durableObjectBindingSchema).optional(), + + /** + * Queue bindings for producers and consumers. + */ + queues: queuesConfigSchema.optional(), + + /** + * Rate Limiting bindings. + */ + rateLimits: z.record(z.string(), rateLimitBindingSchema).optional(), + + /** + * Version Metadata binding. + */ + versionMetadata: versionMetadataBindingSchema.optional(), + + /** + * Worker Loader bindings for Dynamic Workers. + */ + workerLoaders: z.record(z.string(), workerLoaderBindingSchema).optional(), + + /** + * Secrets Store bindings. + */ + secretsStore: z.record(z.string(), secretsStoreBindingSchema).optional(), + + /** + * Service bindings to other Workers. + * Enables RPC-style communication between workers. + */ + services: z.record(z.string(), serviceBindingSchema).optional(), + + /** + * Workers AI binding for ML inference. + */ + ai: aiBindingSchema.optional(), + + /** + * AI Search namespace bindings. + */ + aiSearchNamespaces: z.record(z.string(), aiSearchNamespaceBindingSchema).optional(), + + /** + * AI Search instance bindings. + */ + aiSearch: z.record(z.string(), aiSearchInstanceBindingSchema).optional(), + + /** + * Vectorize index bindings for vector similarity search. + */ + vectorize: z.record(z.string(), vectorizeBindingSchema).optional(), + + /** + * Hyperdrive bindings for accelerated PostgreSQL. + */ + hyperdrive: z.record(z.string(), hyperdriveBindingSchema).optional(), + + /** + * Browser Rendering binding for headless browser access. + */ + browser: browserBindingSchema.optional(), + + /** + * Analytics Engine bindings for event logging. + */ + analyticsEngine: z.record(z.string(), analyticsBindingSchema).optional(), + + /** + * Email sending bindings. + */ + sendEmail: z.record(z.string(), sendEmailBindingSchema).optional(), + + /** + * C17 โ€” mTLS Certificate bindings. + * Maps a binding name to the certificate UUID issued via + * `wrangler mtls-certificate upload`. The runtime exposes the certificate + * to the worker as `env.` for use with `fetch`'s `mTLS` option. + */ + mtlsCertificates: z.record(z.string(), mtlsCertificateBindingSchema).optional(), + + /** + * C17 โ€” Workers for Platforms (Dispatch Namespace) bindings. + * Maps a binding name to the dispatch namespace name. Allows a parent + * worker to look up and dispatch to user workers stored in the namespace. + */ + dispatchNamespaces: z.record(z.string(), dispatchNamespaceBindingSchema).optional(), + + /** + * C17 โ€” Workflows-as-binding. + * Maps a binding name to a workflow class hosted by another worker (or + * the same worker, via `scriptName`). Distinct from `bindings.workflows` + * declarations of workflows defined IN this worker. + */ + workflows: z.record(z.string(), workflowBindingSchema).optional(), + + /** + * C17 โ€” Pipelines bindings. + * Maps a binding name to a Cloudflare Pipelines pipeline (R2-backed + * streaming ingestion). + */ + pipelines: z.record(z.string(), pipelineBindingSchema).optional(), + + /** + * C17 โ€” Cloudflare Images binding. + * Maps a binding name to access the Images service from the worker + * (transformation/upload via `env.`). + */ + images: z.record(z.string(), imagesBindingSchema).optional().superRefine((bindings, ctx) => { + if (!bindings || Object.keys(bindings).length <= 1) { + return + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Wrangler currently supports one Images binding per Worker' + }) + }), + + /** + * C17 โ€” Cloudflare Media Transformations binding. + * Maps a binding name to access the Media Transformations service from + * the worker (video/audio/frame extraction via `env.`). + */ + media: z.record(z.string(), mediaBindingSchema).optional().superRefine((bindings, ctx) => { + if (!bindings || Object.keys(bindings).length <= 1) { + return + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Wrangler currently supports one Media Transformations binding per Worker' + }) + }), + + /** + * C17 โ€” Cloudflare Artifacts bindings. + * Maps a binding name to an Artifacts namespace for Git-compatible + * file storage. + */ + artifacts: z.record(z.string(), artifactsBindingSchema).optional() +}).optional() + +export type BrowserBindings = z.infer +export type BrowserBinding = z.infer +export type D1Binding = z.infer +export type DurableObjectBinding = z.infer +export type HyperdriveBinding = z.infer +export type KVBinding = z.infer +export type QueueConsumer = z.infer +export type QueuesConfig = z.infer +export type RateLimitBinding = z.infer +export type VersionMetadataBinding = z.infer +export type WorkerLoaderBinding = z.infer +export type SecretsStoreBinding = z.infer +export type ServiceBinding = z.infer +export type AiSearchNamespaceBinding = z.infer +export type AiSearchInstanceBinding = z.infer +export type MtlsCertificateBinding = z.infer +export type DispatchNamespaceBinding = z.infer +export type WorkflowBinding = z.infer +export type PipelineBinding = z.infer +export type ImagesBinding = z.infer +export type MediaBinding = z.infer +export type ArtifactsBinding = z.infer diff --git a/packages/devflare/src/config/schema-build.ts b/packages/devflare/src/config/schema-build.ts new file mode 100644 index 0000000..cf827e3 --- /dev/null +++ b/packages/devflare/src/config/schema-build.ts @@ -0,0 +1,58 @@ +import type { OutputOptions, RolldownOptions } from 'rolldown' +import { z } from 'zod' + +export type DevflareRolldownOutputOptions = Omit< + OutputOptions, + 'codeSplitting' | 'dir' | 'file' | 'format' | 'inlineDynamicImports' +> + +export interface DevflareRolldownOptions + extends Omit { + output?: DevflareRolldownOutputOptions +} + +export const rolldownOptionsSchema = z.custom((value) => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}, { + message: 'Expected Rolldown options object' +}) + +/** + * Rolldown configuration for Durable Object bundling. + * Controls Devflare's Rolldown-based DO bundler in local development. + */ +export const rolldownConfigSchema = z.object({ + /** + * Bundle target environment. + * @example 'es2022' + */ + target: z.string().optional(), + /** Enable minification for emitted DO bundles */ + minify: z.boolean().optional(), + /** Generate source maps for emitted DO bundles */ + sourcemap: z.boolean().optional(), + /** + * Additional raw Rolldown options. + * @see https://rolldown.rs/ + */ + options: rolldownOptionsSchema.optional() +}).optional() + +/** + * Vite-related configuration namespace. + * This keeps Vite-specific configuration distinct from Rolldown/DO bundling. + * + * Note: raw Vite build/server configuration still belongs in `vite.config.*`. + * Devflare currently models `plugins` here and leaves room for future Vite-side + * config without overloading the root config shape. + */ +export const viteConfigSchema = z.object({ + /** + * Devflare-level Vite plugin metadata sourced from devflare.config.ts. + * Raw Vite plugin wiring still belongs in `vite.config.*`. + */ + plugins: z.array(z.unknown()).optional() +}).catchall(z.unknown()).optional() + +export type RolldownConfig = z.output +export type ViteConfig = z.output diff --git a/packages/devflare/src/config/schema-env.ts b/packages/devflare/src/config/schema-env.ts new file mode 100644 index 0000000..edb4c8e --- /dev/null +++ b/packages/devflare/src/config/schema-env.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { rootConfigShape } from './schema' + +/** + * Environment-specific configuration overrides. + * + * Derived from the root config shape so that any new root field is + * automatically recognized as an environment override without duplicating + * the field list here. We simply: + * + * - omit fields that are meaningless inside an environment override + * (`accountId`, `wsRoutes`) + * - make everything optional via `.partial()` so consumers can override + * just the bits they need + * - keep `.strict()` so unsupported shorthand (e.g. top-level `plugins` + * or `build`) is rejected at the environment level as well + * + * The root `compatibilityFlags` field already applies + * `normalizeCompatibilityFlags` via its transform, so forced flags are + * injected for environment overrides without extra wiring. + * + * Wrapped in `z.lazy(...)` to break the module-init cycle with `schema.ts` + * (schema.ts references `envConfigSchemaInner` in its `env` field, and this + * module references `rootConfigShape` from schema.ts). + */ +export const envConfigSchema = z.lazy(() => + z.object(rootConfigShape) + .omit({ accountId: true, wsRoutes: true }) + .partial() + .strict() +) + +export const envConfigSchemaInner = envConfigSchema + +export type DevflareEnvConfig = z.output diff --git a/packages/devflare/src/config/schema-normalization.ts b/packages/devflare/src/config/schema-normalization.ts new file mode 100644 index 0000000..d7e3bb9 --- /dev/null +++ b/packages/devflare/src/config/schema-normalization.ts @@ -0,0 +1,422 @@ +import { + formatBrowserBindingLimitMessage, + getBrowserBindingNames, + type ArtifactsBinding, + type BrowserBindings, + type D1Binding, + type DispatchNamespaceBinding, + type DurableObjectBinding, + type HyperdriveBinding, + type ImagesBinding, + type KVBinding, + type MediaBinding, + type MtlsCertificateBinding, + type PipelineBinding, + type SecretsStoreBinding, + type WorkflowBinding +} from './schema-bindings' + +// Re-exported so call sites can format the same message Zod uses without +// importing schema-bindings directly. +export { formatBrowserBindingLimitMessage } + +/** + * Normalized DO binding shape โ€” consistent representation for all DO binding variants. + * Used throughout devflare for DO configuration handling. + */ +export interface NormalizedDOBinding { + /** The DO class name (e.g., 'Counter') */ + className: string + /** + * Optional script name โ€” file path for local DOs, worker name for + * cross-worker DOs. + * + * Prefer the `kind` discriminator below for branching; reach for + * `scriptName` only when you need the actual script identifier. + */ + scriptName?: string + /** Reference result for cross-worker DOs (from ref().DO_NAME) */ + __ref?: unknown + /** + * Discriminator: `'local'` when the DO class is hosted in the current + * worker (no `scriptName`, no `__ref`), `'cross-worker'` when the DO is + * declared via an explicit `scriptName` or via `ref()`. + */ + kind: 'local' | 'cross-worker' +} + +export interface NormalizedD1Binding { + /** Resolved D1 database ID when one is already known */ + databaseId?: string + /** Stable D1 database name when the binding is configured by name */ + name?: string +} + +export interface NormalizedKVBinding { + /** Resolved KV namespace ID when one is already known */ + namespaceId?: string + /** Stable KV namespace name when the binding is configured by name */ + name?: string +} + +export interface NormalizedHyperdriveBinding { + /** Resolved Hyperdrive configuration ID when one is already known */ + configurationId?: string + /** Stable Hyperdrive configuration name when the binding is configured by name */ + name?: string + /** Direct database connection string for local Hyperdrive emulation */ + localConnectionString?: string +} + +export interface NormalizedMtlsCertificateBinding { + /** Uploaded mTLS certificate UUID */ + certificateId: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedDispatchNamespaceBinding { + /** Dispatch namespace name */ + namespace: string + /** Optional outbound Worker config */ + outbound?: { + service: string + environment?: string + parameters?: string[] + } + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedWorkflowBinding { + /** Workflow resource name */ + name: string + /** Exported Workflow class name */ + className: string + /** Optional Worker script name when the Workflow class is external */ + scriptName?: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean + /** Optional Workflow-specific limits */ + limits?: { + steps: number + } +} + +export interface NormalizedPipelineBinding { + /** Pipeline or stream name/id */ + pipeline: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedImagesBinding { + /** Images binding name */ + binding: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedMediaBinding { + /** Media Transformations binding name */ + binding: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedArtifactsBinding { + /** Artifacts namespace */ + namespace: string + /** Wrangler local-development remote-binding preference */ + remote?: boolean +} + +export interface NormalizedSecretsStoreBinding { + /** Secrets Store ID containing the account-level secret */ + storeId: string + /** Secret name within the store */ + secretName: string +} + +/** + * Return the single browser binding name, or `undefined` when no browser + * binding is configured. + * + * invariant: `bindings` is expected to have been validated by + * `browserBindingSchema` (see `schema-bindings.ts`), which rejects + * configurations with more than one browser binding via + * `superRefine` + `formatBrowserBindingLimitMessage`. Callers that bypass + * Zod (e.g. by casting raw input as `DevflareConfig`) should re-validate + * via `browserBindingSchema.parse()` before relying on this selector. + */ +export function getSingleBrowserBindingName(bindings: BrowserBindings | undefined): string | undefined { + const bindingNames = getBrowserBindingNames(bindings) + + if (bindingNames.length === 0) { + return undefined + } + + return bindingNames[0] +} + +/** + * Normalize a DO binding to its object form. + */ +export function normalizeDOBinding(config: DurableObjectBinding): NormalizedDOBinding { + if (typeof config === 'string') { + return { className: config, kind: 'local' } + } + + const scriptName = config.scriptName + const __ref = (config as { __ref?: unknown }).__ref + const kind: 'local' | 'cross-worker' = (scriptName || __ref) ? 'cross-worker' : 'local' + + return { + className: config.className, + scriptName, + __ref, + kind + } +} + +/** + * Normalize a D1 binding to a consistent object form. + * String bindings are treated as stable database names. + */ +export function normalizeD1Binding(config: D1Binding): NormalizedD1Binding { + if (typeof config === 'string') { + return { name: config } + } + + if ('id' in config) { + return { databaseId: config.id } + } + + return { name: config.name } +} + +/** + * Normalize a KV binding to a consistent object form. + * String bindings are treated as stable namespace names. + */ +export function normalizeKVBinding(config: KVBinding): NormalizedKVBinding { + if (typeof config === 'string') { + return { name: config } + } + + if ('id' in config) { + return { namespaceId: config.id } + } + + return { name: config.name } +} + +/** + * Normalize a Hyperdrive binding to a consistent object form. + * String bindings are treated as stable Hyperdrive configuration names. + */ +export function normalizeHyperdriveBinding(config: HyperdriveBinding): NormalizedHyperdriveBinding { + if (typeof config === 'string') { + return { name: config } + } + + const localConnectionString = 'localConnectionString' in config + ? config.localConnectionString + : 'previewLocalConnectionString' in config + ? config.previewLocalConnectionString + : undefined + + if ('id' in config) { + return { + configurationId: config.id, + ...(localConnectionString && { localConnectionString }) + } + } + + return { + name: config.name, + ...(localConnectionString && { localConnectionString }) + } +} + +/** + * Normalize an mTLS certificate binding to Devflare's camelCase shape. + */ +export function normalizeMtlsCertificateBinding( + config: MtlsCertificateBinding +): NormalizedMtlsCertificateBinding { + if (typeof config === 'string') { + return { certificateId: config } + } + + if ('certificateId' in config) { + return { + certificateId: config.certificateId, + ...(config.remote !== undefined && { remote: config.remote }) + } + } + + return { + certificateId: config.certificate_id, + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize a Dispatch Namespace binding to its object form. + */ +export function normalizeDispatchNamespaceBinding( + config: DispatchNamespaceBinding +): NormalizedDispatchNamespaceBinding { + if (typeof config === 'string') { + return { namespace: config } + } + + return { + namespace: config.namespace, + ...(config.outbound && { + outbound: { + service: config.outbound.service, + ...(config.outbound.environment && { environment: config.outbound.environment }), + ...(config.outbound.parameters && { parameters: config.outbound.parameters }) + } + }), + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize a Workflow binding to its object form. + */ +export function normalizeWorkflowBinding( + config: WorkflowBinding +): NormalizedWorkflowBinding { + return { + name: config.name, + className: config.className, + ...(config.scriptName && { scriptName: config.scriptName }), + ...(config.remote !== undefined && { remote: config.remote }), + ...(config.limits && { + limits: { + steps: config.limits.steps + } + }) + } +} + +/** + * Normalize a Pipeline binding to its object form. + */ +export function normalizePipelineBinding( + config: PipelineBinding +): NormalizedPipelineBinding { + if (typeof config === 'string') { + return { pipeline: config } + } + + return { + pipeline: config.pipeline, + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize an Images binding to Wrangler's singleton binding object. + */ +export function normalizeImagesBinding( + binding: string, + config: ImagesBinding +): NormalizedImagesBinding { + if (config === true) { + return { binding } + } + + return { + binding, + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize a Media Transformations binding to Wrangler's singleton binding object. + */ +export function normalizeMediaBinding( + binding: string, + config: MediaBinding +): NormalizedMediaBinding { + if (config === true) { + return { binding } + } + + return { + binding, + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize an Artifacts binding to its object form. + */ +export function normalizeArtifactsBinding( + config: ArtifactsBinding +): NormalizedArtifactsBinding { + if (typeof config === 'string') { + return { namespace: config } + } + + return { + namespace: config.namespace, + ...(config.remote !== undefined && { remote: config.remote }) + } +} + +/** + * Normalize a Secrets Store binding to its explicit store/name form. + */ +export function normalizeSecretsStoreBinding( + config: SecretsStoreBinding, + defaultStoreId?: string, + bindingName = 'unknown' +): NormalizedSecretsStoreBinding { + if (typeof config === 'string') { + if (!defaultStoreId) { + throw new Error( + `Secrets Store binding "${bindingName}" uses shorthand and requires top-level secretsStoreId.` + ) + } + + return { + storeId: defaultStoreId, + secretName: config + } + } + + return { + storeId: config.storeId, + secretName: config.secretName + } +} + +/** + * Get the identifier Devflare should use for local/runtime KV wiring. + */ +export function getLocalKVNamespaceIdentifier(config: KVBinding): string { + const normalized = normalizeKVBinding(config) + return normalized.namespaceId ?? normalized.name ?? '' +} + +/** + * Get the identifier Devflare should use for local/runtime D1 wiring. + */ +export function getLocalD1DatabaseIdentifier(config: D1Binding): string { + const normalized = normalizeD1Binding(config) + return normalized.databaseId ?? normalized.name ?? '' +} + +/** + * Get the identifier Devflare should use for local/runtime Hyperdrive wiring. + */ +export function getLocalHyperdriveConfigIdentifier(config: HyperdriveBinding): string { + const normalized = normalizeHyperdriveBinding(config) + return normalized.configurationId ?? normalized.name ?? '' +} diff --git a/packages/devflare/src/config/schema-runtime.ts b/packages/devflare/src/config/schema-runtime.ts new file mode 100644 index 0000000..d3cccdf --- /dev/null +++ b/packages/devflare/src/config/schema-runtime.ts @@ -0,0 +1,281 @@ +import { z } from 'zod' + +/** Regex pattern for YYYY-MM-DD date format */ +const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Cloudflare Workers compatibility date schema. + * Must be in YYYY-MM-DD format (e.g., '2025-01-07'). + */ +export const compatibilityDateSchema = z.string().regex(dateRegex, { + message: 'Compatibility date must be in YYYY-MM-DD format' +}) + +/** + * Built-in file router configuration used for `src/routes/**` discovery. + */ +export const routesConfigSchema = z.object({ + /** Directory containing route files (e.g., 'src/routes') */ + dir: z.string(), + /** Optional route prefix (e.g., '/api'). */ + prefix: z.string().optional() +}) + +/** + * File handler configuration. + * Maps handler types to their source file paths. + */ +export const filesSchema = z.object({ + fetch: z.union([z.string(), z.literal(false)]).optional(), + queue: z.union([z.string(), z.literal(false)]).optional(), + scheduled: z.union([z.string(), z.literal(false)]).optional(), + email: z.union([z.string(), z.literal(false)]).optional(), + tail: z.union([z.string(), z.literal(false)]).optional(), + durableObjects: z.union([z.string(), z.literal(false)]).optional(), + entrypoints: z.union([z.string(), z.literal(false)]).optional(), + workflows: z.union([z.string(), z.literal(false)]).optional(), + routes: z.union([routesConfigSchema, z.literal(false)]).optional(), + transport: z.union([z.string(), z.null()]).optional() +}).optional() + +/** + * Tail Consumer configuration. + */ +export const tailConsumerSchema = z.union([ + z.string().min(1), + z.object({ + service: z.string().min(1), + environment: z.string().min(1).optional() + }).strict() +]) + +/** + * Trigger configuration for scheduled (cron) events. + */ +export const triggersSchema = z.object({ + crons: z.array(z.string()).optional() +}).optional() + +/** + * Preview-specific Devflare behavior. + */ +export const previewsConfigSchema = z.object({ + includeCrons: z.boolean().optional().default(false) +}).optional() + +/** + * Secret declaration options. + */ +export const secretConfigSchema = z.object({ + required: z.boolean().optional().default(true) +}) + +/** + * Route configuration for worker deployment. + */ +export const routeConfigSchema = z.object({ + pattern: z.string(), + zone_name: z.string().optional(), + zone_id: z.string().optional(), + custom_domain: z.boolean().optional() +}).superRefine((route, ctx) => { + if (!route.custom_domain) { + return + } + + if (route.pattern.includes('*')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['pattern'], + message: 'Wildcard operators (*) are not allowed in Custom Domains' + }) + } + + if (route.pattern.includes('/')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['pattern'], + message: 'Paths are not allowed in Custom Domains' + }) + } +}) + +/** + * WebSocket route configuration for dev mode Durable Object proxying. + */ +export const wsRouteConfigSchema = z.object({ + pattern: z.string(), + doNamespace: z.string(), + idParam: z.string().default('id'), + forwardPath: z.string().default('/websocket') +}) + +/** + * Static assets configuration. + */ +export const assetsConfigSchema = z.object({ + directory: z.string(), + binding: z.string().optional(), + html_handling: z.enum([ + 'auto-trailing-slash', + 'force-trailing-slash', + 'drop-trailing-slash', + 'none' + ]).optional(), + not_found_handling: z.enum([ + 'single-page-application', + '404-page', + 'none' + ]).optional(), + run_worker_first: z.union([ + z.boolean(), + z.array(z.string()) + ]).optional() +}).strict().optional() + +const smartPlacementSchema = z.object({ + mode: z.enum(['off', 'smart']), + hint: z.string().optional() +}).strict().superRefine((placement, ctx) => { + if (placement.hint !== undefined && placement.mode !== 'smart') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['hint'], + message: 'placement.hint can only be set when placement.mode is smart' + }) + } +}) + +const targetedRegionPlacementSchema = z.object({ + mode: z.literal('targeted').optional(), + region: z.string().min(1) +}).strict() + +const targetedHostPlacementSchema = z.object({ + mode: z.literal('targeted').optional(), + host: z.string().min(1) +}).strict() + +const targetedHostnamePlacementSchema = z.object({ + mode: z.literal('targeted').optional(), + hostname: z.string().min(1) +}).strict() + +/** + * Worker placement configuration. + */ +export const placementSchema = z.union([ + smartPlacementSchema, + targetedRegionPlacementSchema, + targetedHostPlacementSchema, + targetedHostnamePlacementSchema +]).optional() + +const samplingRateSchema = z.number().min(0).max(1) + +const observabilityLogsSchema = z.object({ + enabled: z.boolean().optional(), + head_sampling_rate: samplingRateSchema.optional(), + invocation_logs: z.boolean().optional(), + persist: z.boolean().optional(), + destinations: z.array(z.string()).optional() +}).strict() + +const observabilityTracesSchema = z.object({ + enabled: z.boolean().optional(), + head_sampling_rate: samplingRateSchema.optional(), + persist: z.boolean().optional(), + destinations: z.array(z.string()).optional() +}).strict() + +/** + * Observability configuration for logs and traces. + */ +export const observabilitySchema = z.object({ + enabled: z.boolean().optional(), + head_sampling_rate: samplingRateSchema.optional(), + logs: observabilityLogsSchema.optional(), + traces: observabilityTracesSchema.optional() +}).strict().optional() + +/** + * Resource limits configuration. + */ +export const limitsSchema = z.object({ + cpu_ms: z.number().optional(), + subrequests: z.number().optional() +}).strict().optional() + +const rolloutStepPercentageSchema = z.union([ + z.number().int().positive(), + z.array(z.number().int().positive()).min(1) +]) + +/** + * Cloudflare Containers configuration. + * + * Devflare authors this in camelCase and compiles to Wrangler's top-level + * `containers` array. Runtime launch/testing is handled by the local + * container test shim, not by Miniflare itself. + */ +export const containerConfigSchema = z.object({ + className: z.string().min(1), + image: z.string().min(1), + maxInstances: z.number().int().positive().optional(), + instanceType: z.string().min(1).optional(), + name: z.string().min(1).optional(), + imageBuildContext: z.string().min(1).optional(), + imageVars: z.record(z.string(), z.string()).optional(), + rolloutActiveGracePeriod: z.number().int().nonnegative().optional(), + rolloutStepPercentage: rolloutStepPercentageSchema.optional() +}).strict() + +export const containersConfigSchema = z.array(containerConfigSchema).optional() + +/** + * Module rules for non-JavaScript Worker modules and imported assets. + * + * Wrangler also exposes Python-specific rule types while Python Workers are in + * beta. Devflare keeps those behind `wrangler.passthrough` until the local + * Python Worker toolchain has a stable Devflare integration point. + */ +export const moduleRuleSchema = z.object({ + type: z.enum(['ESModule', 'CommonJS', 'CompiledWasm', 'Text', 'Data']), + globs: z.array(z.string()).min(1), + fallthrough: z.boolean().optional() +}).strict() + +export const moduleRulesSchema = z.array(moduleRuleSchema).optional() + +/** + * Durable Object migration configuration. + */ +const renamedClassMigrationSchema = z.object({ + from: z.string(), + to: z.string() +}).strict() + +export const migrationSchema = z.object({ + tag: z.string(), + new_classes: z.array(z.string()).optional(), + renamed_classes: z.array(renamedClassMigrationSchema).optional(), + deleted_classes: z.array(z.string()).optional(), + new_sqlite_classes: z.array(z.string()).optional() +}).strict() + +/** + * Wrangler configuration passthrough. + */ +export const wranglerConfigSchema = z.object({ + passthrough: z.record(z.string(), z.unknown()).optional() +}).optional() + +export type AssetsConfig = z.infer +export type ContainerConfig = z.infer +export type MigrationConfig = z.infer +export type ModuleRuleConfig = z.infer +export type PlacementConfig = z.infer +export type PreviewConfig = z.output +export type RouteConfig = z.infer +export type TailConsumerConfig = z.infer +export type WsRouteConfig = z.infer diff --git a/packages/devflare/src/config/schema-types-bindings-platform.ts b/packages/devflare/src/config/schema-types-bindings-platform.ts new file mode 100644 index 0000000..3de54a9 --- /dev/null +++ b/packages/devflare/src/config/schema-types-bindings-platform.ts @@ -0,0 +1,413 @@ +/** + * Browser Rendering binding value. + */ +export type BrowserBindingInput = string | BrowserBindingObjectInput + +/** + * Browser Rendering binding object form. + */ +export interface BrowserBindingObjectInput { + /** + * Whether Wrangler local development should use the remote Browser + * Rendering service. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * browser: { BROWSER: { remote: true } } + * ``` + */ + remote?: boolean +} + +/** + * Analytics Engine binding configuration. + */ +export interface AnalyticsBindingInput { + /** + * Analytics Engine dataset name. + * + * @example + * ```ts + * analyticsEngine: { EVENTS: { dataset: 'worker_events' } } + * ``` + */ + dataset: string +} + +/** + * Email sending binding configuration. + */ +export interface SendEmailBindingInput { + /** + * Restrict this binding to a specific verified destination address. + * + * @default No single-address restriction. + * + * @example + * ```ts + * destinationAddress: 'ops@example.com' + * ``` + */ + destinationAddress?: string + + /** + * Restrict this binding to a set of verified destination addresses. + * + * @default No destination allow-list restriction. + * + * @example + * ```ts + * allowedDestinationAddresses: ['ops@example.com'] + * ``` + */ + allowedDestinationAddresses?: string[] + + /** + * Restrict this binding to a set of verified sender addresses. + * + * @default No sender allow-list restriction. + * + * @example + * ```ts + * allowedSenderAddresses: ['noreply@example.com'] + * ``` + */ + allowedSenderAddresses?: string[] +} + +/** + * mTLS certificate binding by certificate ID or object form. + */ +export type MtlsCertificateBindingInput = + | string + | MtlsCertificateBindingByIdInput + | MtlsCertificateBindingByWranglerIdInput + +/** + * mTLS certificate binding in Devflare camelCase form. + */ +export interface MtlsCertificateBindingByIdInput { + /** + * Uploaded mTLS certificate UUID from `wrangler mtls-certificate upload`. + * + * @example + * ```ts + * certificateId: 'certificate-uuid' + * ``` + */ + certificateId: string + + /** + * Whether Wrangler local development should use the remote certificate + * binding when available. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * mTLS certificate binding in Wrangler snake_case form. + */ +export interface MtlsCertificateBindingByWranglerIdInput { + /** + * Wrangler-native uploaded mTLS certificate UUID. + * + * @example + * ```ts + * certificate_id: 'certificate-uuid' + * ``` + */ + certificate_id: string + + /** + * Whether Wrangler local development should use the remote certificate + * binding when available. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * Dispatch Namespace binding by namespace name or object form. + */ +export type DispatchNamespaceBindingInput = string | DispatchNamespaceBindingObjectInput + +/** + * Dispatch Namespace binding object form. + */ +export interface DispatchNamespaceBindingObjectInput { + /** + * Dispatch namespace name. + * + * @example + * ```ts + * namespace: 'customers' + * ``` + */ + namespace: string + + /** + * Optional outbound worker binding configuration. + * + * @default No outbound worker binding. + * + * @example + * ```ts + * outbound: { service: 'customer-router' } + * ``` + */ + outbound?: DispatchNamespaceOutboundInput + + /** + * Whether Wrangler local development should use the remote dispatch + * namespace. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * Dispatch Namespace outbound worker binding configuration. + */ +export interface DispatchNamespaceOutboundInput { + /** + * Outbound worker service name. + * + * @example + * ```ts + * service: 'customer-router' + * ``` + */ + service: string + + /** + * Optional outbound worker environment. + * + * @default Target worker default environment. + * + * @example + * ```ts + * environment: 'production' + * ``` + */ + environment?: string + + /** + * Outbound worker parameters. + * + * @default No parameters. + * + * @example + * ```ts + * parameters: ['account_id'] + * ``` + */ + parameters?: string[] +} + +/** + * Workflow binding configuration. + */ +export interface WorkflowBindingInput { + /** + * Workflow binding name compiled for Wrangler. + * + * @example + * ```ts + * name: 'onboarding' + * ``` + */ + name: string + + /** + * Workflow class name. + * + * @example + * ```ts + * className: 'OnboardingWorkflow' + * ``` + */ + className: string + + /** + * Worker script name that hosts the workflow. + * + * @default Current worker. + * + * @example + * ```ts + * scriptName: 'workflow-worker' + * ``` + */ + scriptName?: string + + /** + * Whether Wrangler local development should use the remote workflow + * binding. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean + + /** + * Workflow execution limits. + * + * @default Cloudflare Workflows default behavior. + * + * @example + * ```ts + * limits: { steps: 100 } + * ``` + */ + limits?: WorkflowLimitsInput +} + +/** + * Workflow execution limits. + */ +export interface WorkflowLimitsInput { + /** + * Maximum number of workflow steps. + * + * @example + * ```ts + * steps: 100 + * ``` + */ + steps: number +} + +/** + * Cloudflare Pipelines binding by pipeline name or object form. + */ +export type PipelineBindingInput = string | PipelineBindingObjectInput + +/** + * Cloudflare Pipelines binding object form. + */ +export interface PipelineBindingObjectInput { + /** + * Pipeline name. + * + * @example + * ```ts + * pipeline: 'events-pipeline' + * ``` + */ + pipeline: string + + /** + * Whether Wrangler local development should use the remote pipeline + * binding. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * Cloudflare Images binding value. + */ +export type ImagesBindingInput = true | ImagesBindingObjectInput + +/** + * Cloudflare Images binding object form. + */ +export interface ImagesBindingObjectInput { + /** + * Whether Wrangler local development should use the remote Images service. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * images: { IMAGES: { remote: true } } + * ``` + */ + remote?: boolean +} + +/** + * Cloudflare Media Transformations binding value. + */ +export type MediaBindingInput = true | MediaBindingObjectInput + +/** + * Cloudflare Media Transformations binding object form. + */ +export interface MediaBindingObjectInput { + /** + * Whether Wrangler local development should use the remote Media service. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * media: { MEDIA: { remote: true } } + * ``` + */ + remote?: boolean +} + +/** + * Cloudflare Artifacts binding by namespace name or object form. + */ +export type ArtifactsBindingInput = string | ArtifactsBindingObjectInput + +/** + * Cloudflare Artifacts binding object form. + */ +export interface ArtifactsBindingObjectInput { + /** + * Artifacts namespace name. + * + * @example + * ```ts + * namespace: 'builds' + * ``` + */ + namespace: string + + /** + * Whether Wrangler local development should use the remote Artifacts + * namespace. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} diff --git a/packages/devflare/src/config/schema-types-bindings-resources.ts b/packages/devflare/src/config/schema-types-bindings-resources.ts new file mode 100644 index 0000000..a599093 --- /dev/null +++ b/packages/devflare/src/config/schema-types-bindings-resources.ts @@ -0,0 +1,597 @@ +/** + * KV namespace binding by stable namespace name, explicit ID, or resolver + * object. + * + * @example + * ```ts + * kv: { CACHE: 'cache-local' } + * ``` + */ +export type KVBindingInput = string | KVBindingByIdInput | KVBindingByNameInput + +/** + * KV namespace binding by explicit namespace ID. + */ +export interface KVBindingByIdInput { + /** + * Explicit KV namespace ID. + * + * @example + * ```ts + * kv: { CACHE: { id: 'namespace-id' } } + * ``` + */ + id: string +} + +/** + * KV namespace binding by stable namespace name. + */ +export interface KVBindingByNameInput { + /** + * Stable KV namespace name to resolve at config, build, or deploy time. + * + * @example + * ```ts + * kv: { CACHE: { name: 'cache-local' } } + * ``` + */ + name: string +} + +/** + * D1 database binding by stable database name, explicit ID, or resolver object. + * + * @example + * ```ts + * d1: { DB: 'app-db-local' } + * ``` + */ +export type D1BindingInput = string | D1BindingByIdInput | D1BindingByNameInput + +/** + * D1 database binding by explicit database ID. + */ +export interface D1BindingByIdInput { + /** + * Explicit D1 database ID. + * + * @example + * ```ts + * d1: { DB: { id: 'database-id' } } + * ``` + */ + id: string +} + +/** + * D1 database binding by stable database name. + */ +export interface D1BindingByNameInput { + /** + * Stable D1 database name to resolve at config, build, or deploy time. + * + * @example + * ```ts + * d1: { DB: { name: 'app-db-local' } } + * ``` + */ + name: string +} + +/** + * Durable Object binding by local class name or object form. + */ +export type DurableObjectBindingInput = string | DurableObjectBindingObjectInput + +/** + * Durable Object binding object form. + */ +export interface DurableObjectBindingObjectInput { + /** + * Durable Object class name. + * + * @example + * ```ts + * durableObjects: { COUNTER: { className: 'Counter' } } + * ``` + */ + readonly className: string + + /** + * Script name for cross-worker Durable Object access. + * + * @example + * ```ts + * durableObjects: { COUNTER: { className: 'Counter', scriptName: 'counter-worker' } } + * ``` + */ + readonly scriptName?: string + + /** + * Internal marker used by `ref()` Durable Object bindings. + * + * @internal + */ + readonly __ref?: unknown +} + +/** + * Queue producer and consumer configuration. + */ +export interface QueuesConfigInput { + /** + * Queue producer bindings keyed by runtime binding name. + * + * @example + * ```ts + * producers: { TASK_QUEUE: 'tasks-local' } + * ``` + */ + producers?: Record + + /** + * Queue consumer configurations used to process messages from queues. + * + * @example + * ```ts + * consumers: [{ queue: 'tasks-local', maxBatchSize: 5 }] + * ``` + */ + consumers?: QueueConsumerInput[] +} + +/** + * Queue consumer configuration. + */ +export interface QueueConsumerInput { + /** + * Queue name to consume from. + * + * @example + * ```ts + * { queue: 'tasks-local' } + * ``` + */ + queue: string + + /** + * Maximum messages per batch. + * + * @default `10` + * + * @example + * ```ts + * { queue: 'tasks-local', maxBatchSize: 25 } + * ``` + */ + maxBatchSize?: number + + /** + * Maximum seconds to wait before dispatching a partial batch. + * + * @default `5` + * + * @example + * ```ts + * { queue: 'tasks-local', maxBatchTimeout: 10 } + * ``` + */ + maxBatchTimeout?: number + + /** + * Maximum retry attempts for failed messages. + * + * @default `3` + * + * @example + * ```ts + * { queue: 'tasks-local', maxRetries: 5 } + * ``` + */ + maxRetries?: number + + /** + * Queue name that receives failed messages after retries are exhausted. + * + * @default No dead-letter queue. + * + * @example + * ```ts + * { queue: 'tasks-local', deadLetterQueue: 'tasks-dlq-local' } + * ``` + */ + deadLetterQueue?: string + + /** + * Maximum concurrent batch invocations. + * + * @default Cloudflare Queues default behavior. + * + * @example + * ```ts + * { queue: 'tasks-local', maxConcurrency: 4 } + * ``` + */ + maxConcurrency?: number + + /** + * Delay in seconds between retries. + * + * @default Cloudflare Queues default behavior. + * + * @example + * ```ts + * { queue: 'tasks-local', retryDelay: 30 } + * ``` + */ + retryDelay?: number +} + +/** + * Rate Limiting binding configuration. + */ +export interface RateLimitBindingInput { + /** + * Positive integer namespace ID unique to the Cloudflare account. + * + * @example + * ```ts + * namespaceId: '1001' + * ``` + */ + namespaceId: string + + /** + * Simple rate limiting settings. + * + * @example + * ```ts + * simple: { limit: 100, period: 60 } + * ``` + */ + simple: RateLimitSimpleInput +} + +/** + * Simple rate limiting settings. + */ +export interface RateLimitSimpleInput { + /** + * Number of allowed calls within the configured period. + * + * @example + * ```ts + * limit: 100 + * ``` + */ + limit: number + + /** + * Rate limit window in seconds. + * + * @example + * ```ts + * period: 60 + * ``` + */ + period: 10 | 60 +} + +/** + * Version Metadata binding configuration. + */ +export interface VersionMetadataBindingInput { + /** + * Binding name exposed in `env`. + * + * @example + * ```ts + * versionMetadata: { binding: 'CF_VERSION_METADATA' } + * ``` + */ + binding: string +} + +/** + * Worker Loader binding configuration for Dynamic Workers. + */ +export interface WorkerLoaderBindingInput {} + +/** + * Secrets Store binding by shorthand secret name or explicit store object. + */ +export type SecretsStoreBindingInput = string | SecretsStoreBindingObjectInput + +/** + * Explicit Secrets Store binding configuration. + */ +export interface SecretsStoreBindingObjectInput { + /** + * Secrets Store ID containing the account-level secret. + * + * @example + * ```ts + * storeId: 'store-id' + * ``` + */ + storeId: string + + /** + * Secret name within the store. + * + * @example + * ```ts + * secretName: 'API_TOKEN' + * ``` + */ + secretName: string +} + +/** + * Service binding object, including values produced by `ref().worker`. + */ +export interface ServiceBindingInput { + /** + * Target worker service name. + * + * @example + * ```ts + * services: { API: { service: 'api-worker' } } + * ``` + */ + readonly service: string + + /** + * Optional target worker environment. + * + * @default Target worker default environment. + * + * @example + * ```ts + * services: { API: { service: 'api-worker', environment: 'staging' } } + * ``` + */ + readonly environment?: string + + /** + * Optional named WorkerEntrypoint class. + * + * @default Target worker default export. + * + * @example + * ```ts + * services: { API: { service: 'api-worker', entrypoint: 'ApiEntrypoint' } } + * ``` + */ + readonly entrypoint?: string + + /** + * Internal marker used by `ref()` service bindings. + * + * @internal + */ + readonly __ref?: unknown +} + +/** + * Workers AI binding configuration. + */ +export interface AiBindingInput { + /** + * Binding name exposed in `env`. + * + * @example + * ```ts + * ai: { binding: 'AI' } + * ``` + */ + binding: string + + /** + * Whether Wrangler local development should use the remote Workers AI + * service for this binding. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * ai: { binding: 'AI', remote: true } + * ``` + */ + remote?: boolean + + /** + * Whether to use Cloudflare's staging Workers AI environment. + * + * @default `false` + * + * @example + * ```ts + * ai: { binding: 'AI', staging: true } + * ``` + */ + staging?: boolean +} + +/** + * AI Search namespace binding configuration. + */ +export interface AiSearchNamespaceBindingInput { + /** + * AI Search namespace name. + * + * @example + * ```ts + * namespace: 'docs' + * ``` + */ + namespace: string + + /** + * Whether Wrangler local development should use the remote AI Search + * namespace. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * AI Search instance binding configuration. + */ +export interface AiSearchInstanceBindingInput { + /** + * AI Search instance name in the default namespace. + * + * @example + * ```ts + * instanceName: 'docs-search' + * ``` + */ + instanceName: string + + /** + * Whether Wrangler local development should use the remote AI Search + * instance. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * Vectorize index binding configuration. + */ +export interface VectorizeBindingInput { + /** + * Vectorize index name. + * + * @example + * ```ts + * indexName: 'docs-index' + * ``` + */ + indexName: string + + /** + * Whether Wrangler local development should use the remote Vectorize + * index. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * remote: true + * ``` + */ + remote?: boolean +} + +/** + * Hyperdrive binding by stable name, explicit ID, or resolver object. + */ +export type HyperdriveBindingInput = string | HyperdriveBindingByIdInput | HyperdriveBindingByNameInput + +/** + * Hyperdrive binding by explicit configuration ID. + */ +export interface HyperdriveBindingByIdInput { + /** + * Explicit Hyperdrive configuration ID. + * + * @example + * ```ts + * id: 'hyperdrive-id' + * ``` + */ + id: string + + /** + * Direct database connection string used by local Miniflare or Wrangler + * development. + * + * @default No local override. + * + * @example + * ```ts + * localConnectionString: 'postgres://localhost/app' + * ``` + */ + localConnectionString?: string +} + +/** + * Hyperdrive binding by stable configuration name. + */ +export interface HyperdriveBindingByNameInput { + /** + * Stable Hyperdrive configuration name. + * + * @example + * ```ts + * name: 'app-postgres' + * ``` + */ + name: string + + /** + * Direct database connection string used by local Miniflare or Wrangler + * development. + * + * @default No local override. + * + * @example + * ```ts + * localConnectionString: 'postgres://localhost/app' + * ``` + */ + localConnectionString?: string + + /** + * Preview fallback behavior when no dedicated preview Hyperdrive exists. + * + * @default Missing preview Hyperdrives fail config resolution. + * + * @example + * ```ts + * previewFallback: 'base' + * ``` + */ + previewFallback?: 'base' + + /** + * Explicit dedicated preview Hyperdrive configuration ID. + * + * @default No dedicated preview ID. + * + * @example + * ```ts + * previewId: 'preview-hyperdrive-id' + * ``` + */ + previewId?: string + + /** + * Legacy alias for a preview or development local connection string. + * Prefer `localConnectionString`. + * + * @default No local override. + * + * @example + * ```ts + * previewLocalConnectionString: 'postgres://localhost/app_preview' + * ``` + */ + previewLocalConnectionString?: string +} diff --git a/packages/devflare/src/config/schema-types-bindings.ts b/packages/devflare/src/config/schema-types-bindings.ts new file mode 100644 index 0000000..4baae5f --- /dev/null +++ b/packages/devflare/src/config/schema-types-bindings.ts @@ -0,0 +1,306 @@ +import type { + AiBindingInput, + AiSearchInstanceBindingInput, + AiSearchNamespaceBindingInput, + D1BindingInput, + DurableObjectBindingInput, + HyperdriveBindingInput, + KVBindingInput, + QueueConsumerInput, + QueuesConfigInput, + RateLimitBindingInput, + SecretsStoreBindingInput, + ServiceBindingInput, + VectorizeBindingInput, + VersionMetadataBindingInput, + WorkerLoaderBindingInput +} from './schema-types-bindings-resources' +import type { + AnalyticsBindingInput, + ArtifactsBindingInput, + BrowserBindingInput, + DispatchNamespaceBindingInput, + ImagesBindingInput, + MediaBindingInput, + MtlsCertificateBindingInput, + PipelineBindingInput, + SendEmailBindingInput, + WorkflowBindingInput +} from './schema-types-bindings-platform' + +export interface BindingsConfigInput { + /** + * KV namespace bindings keyed by runtime binding name. + * + * @example + * ```ts + * kv: { CACHE: 'my-cache' } + * ``` + */ + kv?: Record + + /** + * D1 database bindings keyed by runtime binding name. + * + * @example + * ```ts + * d1: { DB: 'my-database' } + * ``` + */ + d1?: Record + + /** + * R2 bucket bindings keyed by runtime binding name. + * + * @example + * ```ts + * r2: { BUCKET: 'uploads-local' } + * ``` + */ + r2?: Record + + /** + * Durable Object bindings keyed by runtime binding name. + * + * @example + * ```ts + * durableObjects: { COUNTER: 'Counter' } + * ``` + */ + durableObjects?: Record + + /** + * Queue producer and consumer bindings. + * + * @example + * ```ts + * queues: { + * producers: { TASKS: 'tasks-local' }, + * consumers: [{ queue: 'tasks-local' }] + * } + * ``` + */ + queues?: QueuesConfigInput + + /** + * Rate Limiting bindings keyed by runtime binding name. + * + * @example + * ```ts + * rateLimits: { + * RATE_LIMITER: { + * namespaceId: '1001', + * simple: { limit: 100, period: 60 } + * } + * } + * ``` + */ + rateLimits?: Record + + /** + * Version Metadata binding. + * + * @example + * ```ts + * versionMetadata: { binding: 'CF_VERSION_METADATA' } + * ``` + */ + versionMetadata?: VersionMetadataBindingInput + + /** + * Worker Loader bindings for Dynamic Workers. + * + * @example + * ```ts + * workerLoaders: { LOADER: {} } + * ``` + */ + workerLoaders?: Record + + /** + * Secrets Store bindings keyed by runtime binding name. + * + * @example + * ```ts + * secretsStore: { + * API_TOKEN: { storeId: 'store-id', secretName: 'API_TOKEN' } + * } + * ``` + */ + secretsStore?: Record + + /** + * Service bindings to other Workers, including `ref().worker` outputs. + * + * @example + * ```ts + * services: { + * API: apiWorker.worker('ApiEntrypoint') + * } + * ``` + */ + services?: Record + + /** + * Workers AI binding. + * + * @example + * ```ts + * ai: { binding: 'AI' } + * ``` + */ + ai?: AiBindingInput + + /** + * AI Search namespace bindings keyed by runtime binding name. + * + * @example + * ```ts + * aiSearchNamespaces: { SEARCH: { namespace: 'docs' } } + * ``` + */ + aiSearchNamespaces?: Record + + /** + * AI Search instance bindings keyed by runtime binding name. + * + * @example + * ```ts + * aiSearch: { SEARCH: { instanceName: 'docs-search' } } + * ``` + */ + aiSearch?: Record + + /** + * Vectorize index bindings keyed by runtime binding name. + * + * @example + * ```ts + * vectorize: { VECTORIZE: { indexName: 'docs-index' } } + * ``` + */ + vectorize?: Record + + /** + * Hyperdrive bindings keyed by runtime binding name. + * + * @example + * ```ts + * hyperdrive: { + * DB: { name: 'postgres', localConnectionString: 'postgres://localhost/db' } + * } + * ``` + */ + hyperdrive?: Record + + /** + * Browser Rendering bindings. Wrangler currently supports one browser + * binding per Worker. + * + * @example + * ```ts + * browser: { BROWSER: { remote: true } } + * ``` + */ + browser?: Record + + /** + * Analytics Engine bindings keyed by runtime binding name. + * + * @example + * ```ts + * analyticsEngine: { EVENTS: { dataset: 'worker_events' } } + * ``` + */ + analyticsEngine?: Record + + /** + * Email sending bindings keyed by runtime binding name. + * + * @example + * ```ts + * sendEmail: { + * EMAIL: { allowedDestinationAddresses: ['ops@example.com'] } + * } + * ``` + */ + sendEmail?: Record + + /** + * mTLS certificate bindings keyed by runtime binding name. + * + * @example + * ```ts + * mtlsCertificates: { CERT: 'certificate-uuid' } + * ``` + */ + mtlsCertificates?: Record + + /** + * Workers for Platforms dispatch namespace bindings keyed by runtime + * binding name. + * + * @example + * ```ts + * dispatchNamespaces: { DISPATCHER: 'customers' } + * ``` + */ + dispatchNamespaces?: Record + + /** + * Workflow bindings keyed by runtime binding name. + * + * @example + * ```ts + * workflows: { + * ONBOARDING: { name: 'onboarding', className: 'OnboardingWorkflow' } + * } + * ``` + */ + workflows?: Record + + /** + * Cloudflare Pipelines bindings keyed by runtime binding name. + * + * @example + * ```ts + * pipelines: { EVENTS: 'events-pipeline' } + * ``` + */ + pipelines?: Record + + /** + * Cloudflare Images service bindings keyed by runtime binding name. + * Wrangler currently supports one Images binding per Worker. + * + * @example + * ```ts + * images: { IMAGES: true } + * ``` + */ + images?: Record + + /** + * Cloudflare Media Transformations bindings keyed by runtime binding name. + * Wrangler currently supports one Media binding per Worker. + * + * @example + * ```ts + * media: { MEDIA: { remote: true } } + * ``` + */ + media?: Record + + /** + * Cloudflare Artifacts bindings keyed by runtime binding name. + * + * @example + * ```ts + * artifacts: { ARTIFACTS: { namespace: 'builds' } } + * ``` + */ + artifacts?: Record +} + +export type * from './schema-types-bindings-resources' +export type * from './schema-types-bindings-platform' diff --git a/packages/devflare/src/config/schema-types-build.ts b/packages/devflare/src/config/schema-types-build.ts new file mode 100644 index 0000000..a40d64e --- /dev/null +++ b/packages/devflare/src/config/schema-types-build.ts @@ -0,0 +1,92 @@ +import type { DevflareRolldownOptions } from './schema-build' + +/** + * Rolldown configuration for Devflare's Durable Object bundler. + */ +export interface RolldownConfigInput { + /** + * Bundle target environment. + * + * @example + * ```ts + * target: 'es2022' + * ``` + */ + target?: string + + /** + * Enable minification for emitted Durable Object bundles. + * + * @default `false` + * + * @example + * ```ts + * minify: true + * ``` + */ + minify?: boolean + + /** + * Generate source maps for emitted Durable Object bundles. + * + * @default `false` + * + * @example + * ```ts + * sourcemap: true + * ``` + */ + sourcemap?: boolean + + /** + * Additional raw Rolldown options. + * + * @default Devflare-managed Rolldown options. + * + * @example + * ```ts + * options: { external: ['node:fs'] } + * ``` + */ + options?: DevflareRolldownOptions +} + +/** + * Devflare Vite configuration namespace. + */ +export interface ViteConfigInput { + /** + * Devflare-level Vite plugin metadata. + * + * @default No Devflare plugin metadata. + * + * @example + * ```ts + * plugins: [] + * ``` + */ + plugins?: unknown[] + + /** + * Additional future Devflare Vite options. + */ + [key: string]: unknown +} + +/** + * Wrangler passthrough configuration. + */ +export interface WranglerConfigInput { + /** + * Raw Wrangler options that Devflare should pass through without direct + * modeling. + * + * @default No passthrough options. + * + * @example + * ```ts + * passthrough: { main: '.svelte-kit/cloudflare/_worker.js' } + * ``` + */ + passthrough?: Record +} diff --git a/packages/devflare/src/config/schema-types-runtime.ts b/packages/devflare/src/config/schema-types-runtime.ts new file mode 100644 index 0000000..8b12910 --- /dev/null +++ b/packages/devflare/src/config/schema-types-runtime.ts @@ -0,0 +1,961 @@ +export interface PreviewConfigInput { + /** + * Whether preview-generated config should include cron triggers. + * + * @default `false` + * + * @example + * ```ts + * previews: { includeCrons: true } + * ``` + */ + includeCrons?: boolean +} + +/** + * Source file discovery for Worker handlers and generated support files. + */ +export interface FilesConfigInput { + /** + * HTTP fetch entrypoint source file or `false` to disable fetch handler + * discovery. + * + * @default Auto-discovered from conventional fetch handler paths. + * + * @example + * ```ts + * files: { fetch: 'src/fetch.ts' } + * ``` + */ + fetch?: string | false + + /** + * Queue handler source file or `false` to disable queue handler discovery. + * + * @default Auto-discovered from conventional queue handler paths. + * + * @example + * ```ts + * files: { queue: 'src/queue.ts' } + * ``` + */ + queue?: string | false + + /** + * Scheduled event handler source file or `false` to disable scheduled + * handler discovery. + * + * @default Auto-discovered from conventional scheduled handler paths. + * + * @example + * ```ts + * files: { scheduled: 'src/scheduled.ts' } + * ``` + */ + scheduled?: string | false + + /** + * Email event handler source file or `false` to disable email handler + * discovery. + * + * @default Auto-discovered from conventional email handler paths. + * + * @example + * ```ts + * files: { email: 'src/email.ts' } + * ``` + */ + email?: string | false + + /** + * Tail event handler source file or `false` to disable tail handler + * discovery. + * + * @default Auto-discovered from conventional tail handler paths. + * + * @example + * ```ts + * files: { tail: 'src/tail.ts' } + * ``` + */ + tail?: string | false + + /** + * Durable Object source file glob or `false` to disable Durable Object + * class discovery. + * + * @default Auto-discovered from conventional Durable Object file paths. + * + * @example + * ```ts + * files: { durableObjects: 'src/do.*.ts' } + * ``` + */ + durableObjects?: string | false + + /** + * Named WorkerEntrypoint source file glob or `false` to disable entrypoint + * discovery. + * + * @default Auto-discovered from conventional entrypoint file paths. + * + * @example + * ```ts + * files: { entrypoints: 'src/ep.*.ts' } + * ``` + */ + entrypoints?: string | false + + /** + * Workflow source file glob or `false` to disable workflow discovery. + * + * @default Auto-discovered from conventional workflow file paths. + * + * @example + * ```ts + * files: { workflows: 'src/wf.*.ts' } + * ``` + */ + workflows?: string | false + + /** + * Built-in route-file discovery configuration or `false` to disable route + * file discovery. + * + * @default Auto-discovered from conventional route file paths. + * + * @example + * ```ts + * files: { + * routes: { dir: 'src/routes', prefix: '/api' } + * } + * ``` + */ + routes?: RouteTreeConfigInput | false + + /** + * Internal transport worker source file. Set `null` to suppress transport + * file generation when a command supports doing so. + * + * @default Devflare-managed transport file. + * + * @example + * ```ts + * files: { transport: 'src/.devflare/transport.ts' } + * ``` + */ + transport?: string | null +} + +/** + * Built-in file router discovery configuration. + */ +export interface RouteTreeConfigInput { + /** + * Directory containing route files. + * + * @example + * ```ts + * routes: { dir: 'src/routes' } + * ``` + */ + dir: string + + /** + * URL prefix added before discovered route paths. + * + * @default No prefix. + * + * @example + * ```ts + * routes: { dir: 'src/routes', prefix: '/api' } + * ``` + */ + prefix?: string +} + +/** + * Trigger configuration for scheduled events. + */ +export interface TriggersConfigInput { + /** + * Cron expressions that invoke the Worker scheduled handler. + * + * @example + * ```ts + * triggers: { crons: ['0 * * * *'] } + * ``` + */ + crons?: string[] +} + +/** + * Wrangler module rule configuration. + */ +export interface ModuleRuleConfigInput { + /** + * Module rule type. + * + * @example + * ```ts + * type: 'Text' + * ``` + */ + type: 'ESModule' | 'CommonJS' | 'CompiledWasm' | 'Text' | 'Data' + + /** + * Glob patterns matched by this module rule. + * + * @example + * ```ts + * globs: ['content/*.txt'] + * ``` + */ + globs: string[] + + /** + * Whether Wrangler should continue evaluating later rules after this one. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * fallthrough: true + * ``` + */ + fallthrough?: boolean +} + +/** + * Tail Worker consumer configuration. + */ +export type TailConsumerConfigInput = string | TailConsumerObjectConfigInput + +/** + * Tail Worker consumer object form. + */ +export interface TailConsumerObjectConfigInput { + /** + * Tail Worker service name. + * + * @example + * ```ts + * service: 'trace-worker' + * ``` + */ + service: string + + /** + * Optional Tail Worker environment. + * + * @default Target worker default environment. + * + * @example + * ```ts + * environment: 'production' + * ``` + */ + environment?: string +} + +/** + * Secret declaration options. + */ +export interface SecretConfigInput { + /** + * Whether the secret is required during validation. + * + * @default `true` + * + * @example + * ```ts + * secrets: { API_TOKEN: { required: true } } + * ``` + */ + required?: boolean +} + +/** + * Cloudflare Worker route configuration. + */ +export interface RouteConfigInput { + /** + * Route pattern handled by the Worker. + * + * @example + * ```ts + * { pattern: 'api.example.com/*' } + * ``` + */ + pattern: string + + /** + * Zone name for the route. + * + * @default Cloudflare resolves the zone from the route pattern when + * possible. + * + * @example + * ```ts + * { pattern: 'api.example.com/*', zone_name: 'example.com' } + * ``` + */ + zone_name?: string + + /** + * Zone ID for the route. + * + * @default Cloudflare resolves the zone from the route pattern when + * possible. + * + * @example + * ```ts + * { pattern: 'api.example.com/*', zone_id: 'zone-id' } + * ``` + */ + zone_id?: string + + /** + * Whether this route is a custom domain route instead of a wildcard route. + * Custom Domains attach the Worker to the whole hostname, so the pattern + * must be a bare host such as `worker.example.com`. Use a normal route + * with `zone_name` or `zone_id` for wildcard or path patterns. + * + * @default `false` + * + * @example + * ```ts + * routes: [ + * { pattern: 'worker.example.com', custom_domain: true } + * ] + * ``` + */ + custom_domain?: boolean +} + +/** + * Local WebSocket route for Durable Object proxying. + */ +export interface WsRouteConfigInput { + /** + * Local route pattern. + * + * @example + * ```ts + * pattern: '/rooms/:id' + * ``` + */ + pattern: string + + /** + * Durable Object namespace binding name that should receive the socket. + * + * @example + * ```ts + * doNamespace: 'ROOM' + * ``` + */ + doNamespace: string + + /** + * Route parameter used as the Durable Object ID. + * + * @default `'id'` + * + * @example + * ```ts + * idParam: 'roomId' + * ``` + */ + idParam?: string + + /** + * Path forwarded to the Durable Object when the socket is proxied. + * + * @default `'/websocket'` + * + * @example + * ```ts + * forwardPath: '/connect' + * ``` + */ + forwardPath?: string +} + +/** + * Static assets configuration. + */ +export interface AssetsConfigInput { + /** + * Directory containing static assets. + * + * @example + * ```ts + * assets: { directory: './dist' } + * ``` + */ + directory: string + + /** + * Optional asset binding name exposed to the Worker. + * + * @default Wrangler default binding behavior. + * + * @example + * ```ts + * assets: { directory: './dist', binding: 'ASSETS' } + * ``` + */ + binding?: string + + /** + * HTML path handling behavior. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * html_handling: 'auto-trailing-slash' + * ``` + */ + html_handling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none' + + /** + * Not-found handling behavior. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * not_found_handling: 'single-page-application' + * ``` + */ + not_found_handling?: 'single-page-application' | '404-page' | 'none' + + /** + * Whether the Worker should run before serving assets, or the asset path + * patterns that should run the Worker first. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * run_worker_first: ['/api/*'] + * ``` + */ + run_worker_first?: boolean | string[] +} + +/** + * Cloudflare Container configuration. + */ +export interface ContainerConfigInput { + /** + * Container class name exported by the Worker. + * + * @example + * ```ts + * className: 'RendererContainer' + * ``` + */ + className: string + + /** + * Container image reference or build target. + * + * @example + * ```ts + * image: './Dockerfile' + * ``` + */ + image: string + + /** + * Maximum number of container instances. + * + * @default Cloudflare Containers default behavior. + * + * @example + * ```ts + * maxInstances: 3 + * ``` + */ + maxInstances?: number + + /** + * Container instance type. + * + * @default Cloudflare Containers default behavior. + * + * @example + * ```ts + * instanceType: 'standard' + * ``` + */ + instanceType?: string + + /** + * Optional generated container name. + * + * @default Devflare derives the name from the class when possible. + * + * @example + * ```ts + * name: 'renderer' + * ``` + */ + name?: string + + /** + * Docker build context. + * + * @default Container image path default. + * + * @example + * ```ts + * imageBuildContext: './containers/renderer' + * ``` + */ + imageBuildContext?: string + + /** + * Build-time variables for the container image. + * + * @default No image variables. + * + * @example + * ```ts + * imageVars: { NODE_ENV: 'production' } + * ``` + */ + imageVars?: Record + + /** + * Active grace period in seconds during rollout. + * + * @default Cloudflare Containers default behavior. + * + * @example + * ```ts + * rolloutActiveGracePeriod: 60 + * ``` + */ + rolloutActiveGracePeriod?: number + + /** + * Rollout step percentage or step percentages. + * + * @default Cloudflare Containers default behavior. + * + * @example + * ```ts + * rolloutStepPercentage: [10, 50, 100] + * ``` + */ + rolloutStepPercentage?: number | number[] +} + +/** + * Worker placement configuration. + */ +export type PlacementConfigInput = + | SmartPlacementConfigInput + | TargetedRegionPlacementConfigInput + | TargetedHostPlacementConfigInput + | TargetedHostnamePlacementConfigInput + +/** + * Smart Placement configuration. + */ +export interface SmartPlacementConfigInput { + /** + * Smart Placement mode. + * + * @example + * ```ts + * mode: 'smart' + * ``` + */ + mode: 'off' | 'smart' + + /** + * Optional placement hint. Only valid when `mode` is `smart`. + * + * @default No placement hint. + * + * @example + * ```ts + * hint: 'wnam' + * ``` + */ + hint?: string +} + +/** + * Targeted placement by Cloudflare region. + */ +export interface TargetedRegionPlacementConfigInput { + /** + * Targeted placement mode. + * + * @default `'targeted'` + * + * @example + * ```ts + * mode: 'targeted' + * ``` + */ + mode?: 'targeted' + + /** + * Target Cloudflare region. + * + * @example + * ```ts + * region: 'wnam' + * ``` + */ + region: string +} + +/** + * Targeted placement by host. + */ +export interface TargetedHostPlacementConfigInput { + /** + * Targeted placement mode. + * + * @default `'targeted'` + * + * @example + * ```ts + * mode: 'targeted' + * ``` + */ + mode?: 'targeted' + + /** + * Target host. + * + * @example + * ```ts + * host: 'db.internal' + * ``` + */ + host: string +} + +/** + * Targeted placement by hostname. + */ +export interface TargetedHostnamePlacementConfigInput { + /** + * Targeted placement mode. + * + * @default `'targeted'` + * + * @example + * ```ts + * mode: 'targeted' + * ``` + */ + mode?: 'targeted' + + /** + * Target hostname. + * + * @example + * ```ts + * hostname: 'api.example.com' + * ``` + */ + hostname: string +} + +/** + * Worker resource limits. + */ +export interface LimitsConfigInput { + /** + * CPU time limit in milliseconds. + * + * @default Cloudflare Workers account and plan default. + * + * @example + * ```ts + * cpu_ms: 50 + * ``` + */ + cpu_ms?: number + + /** + * Subrequest limit. + * + * @default Cloudflare Workers account and plan default. + * + * @example + * ```ts + * subrequests: 1000 + * ``` + */ + subrequests?: number +} + +/** + * Worker logs and traces observability settings. + */ +export interface ObservabilityConfigInput { + /** + * Enable observability. + * + * @default Cloudflare default behavior. + * + * @example + * ```ts + * enabled: true + * ``` + */ + enabled?: boolean + + /** + * Head sampling rate from `0` to `1`. + * + * @default Cloudflare default sampling behavior. + * + * @example + * ```ts + * head_sampling_rate: 0.1 + * ``` + */ + head_sampling_rate?: number + + /** + * Log-specific observability settings. + * + * @default Cloudflare default log behavior. + * + * @example + * ```ts + * logs: { enabled: true, invocation_logs: true } + * ``` + */ + logs?: ObservabilityLogsConfigInput + + /** + * Trace-specific observability settings. + * + * @default Cloudflare default trace behavior. + * + * @example + * ```ts + * traces: { enabled: true, head_sampling_rate: 0.05 } + * ``` + */ + traces?: ObservabilityTracesConfigInput +} + +/** + * Worker log observability settings. + */ +export interface ObservabilityLogsConfigInput { + /** + * Enable log collection. + * + * @default Cloudflare default log behavior. + * + * @example + * ```ts + * enabled: true + * ``` + */ + enabled?: boolean + + /** + * Log head sampling rate from `0` to `1`. + * + * @default Cloudflare default sampling behavior. + * + * @example + * ```ts + * head_sampling_rate: 0.1 + * ``` + */ + head_sampling_rate?: number + + /** + * Include invocation logs. + * + * @default Cloudflare default log behavior. + * + * @example + * ```ts + * invocation_logs: true + * ``` + */ + invocation_logs?: boolean + + /** + * Persist logs. + * + * @default Cloudflare default persistence behavior. + * + * @example + * ```ts + * persist: true + * ``` + */ + persist?: boolean + + /** + * Log destination names. + * + * @default Cloudflare default destinations. + * + * @example + * ```ts + * destinations: ['cloudflare'] + * ``` + */ + destinations?: string[] +} + +/** + * Worker trace observability settings. + */ +export interface ObservabilityTracesConfigInput { + /** + * Enable trace collection. + * + * @default Cloudflare default trace behavior. + * + * @example + * ```ts + * enabled: true + * ``` + */ + enabled?: boolean + + /** + * Trace head sampling rate from `0` to `1`. + * + * @default Cloudflare default sampling behavior. + * + * @example + * ```ts + * head_sampling_rate: 0.1 + * ``` + */ + head_sampling_rate?: number + + /** + * Persist traces. + * + * @default Cloudflare default persistence behavior. + * + * @example + * ```ts + * persist: true + * ``` + */ + persist?: boolean + + /** + * Trace destination names. + * + * @default Cloudflare default destinations. + * + * @example + * ```ts + * destinations: ['cloudflare'] + * ``` + */ + destinations?: string[] +} + +/** + * Durable Object migration configuration. + */ +export interface MigrationConfigInput { + /** + * Unique migration tag. + * + * @example + * ```ts + * tag: 'v1' + * ``` + */ + tag: string + + /** + * New Durable Object class names using legacy storage. + * + * @default No new legacy-storage classes. + * + * @example + * ```ts + * new_classes: ['Counter'] + * ``` + */ + new_classes?: string[] + + /** + * Renamed Durable Object classes. + * + * @default No renamed classes. + * + * @example + * ```ts + * renamed_classes: [{ from: 'OldCounter', to: 'Counter' }] + * ``` + */ + renamed_classes?: RenamedClassMigrationInput[] + + /** + * Deleted Durable Object class names. + * + * @default No deleted classes. + * + * @example + * ```ts + * deleted_classes: ['OldCounter'] + * ``` + */ + deleted_classes?: string[] + + /** + * New Durable Object class names using SQLite storage. + * + * @default No new SQLite classes. + * + * @example + * ```ts + * new_sqlite_classes: ['Counter'] + * ``` + */ + new_sqlite_classes?: string[] +} + +/** + * Durable Object class rename migration entry. + */ +export interface RenamedClassMigrationInput { + /** + * Previous Durable Object class name. + * + * @example + * ```ts + * from: 'OldCounter' + * ``` + */ + from: string + + /** + * New Durable Object class name. + * + * @example + * ```ts + * to: 'Counter' + * ``` + */ + to: string +} diff --git a/packages/devflare/src/config/schema-types.ts b/packages/devflare/src/config/schema-types.ts new file mode 100644 index 0000000..9f007f6 --- /dev/null +++ b/packages/devflare/src/config/schema-types.ts @@ -0,0 +1,424 @@ +import type { BindingsConfigInput } from './schema-types-bindings' +import type { DevflareVarsInput } from './env-vars' +import type { + RolldownConfigInput, + ViteConfigInput, + WranglerConfigInput +} from './schema-types-build' +import type { + AssetsConfigInput, + ContainerConfigInput, + FilesConfigInput, + LimitsConfigInput, + MigrationConfigInput, + ModuleRuleConfigInput, + ObservabilityConfigInput, + PlacementConfigInput, + PreviewConfigInput, + RouteConfigInput, + SecretConfigInput, + TailConsumerConfigInput, + TriggersConfigInput, + WsRouteConfigInput +} from './schema-types-runtime' + +/** + * Authoring input accepted by `defineConfig()`. + * + * This type is intentionally written by hand instead of inferred from the Zod + * schema so editors can surface property documentation, defaults, and examples + * while users write `devflare.config.ts`. + * + * @example + * ```ts + * import { defineConfig, ref } from 'devflare/config' + * + * const api = ref('api-worker', () => import('../api/devflare.config')) + * + * export default defineConfig({ + * name: 'site-worker', + * compatibilityDate: '2026-05-01', + * files: { fetch: 'src/fetch.ts' }, + * bindings: { + * services: { + * API: api.worker('ApiEntrypoint') + * } + * }, + * routes: [ + * { pattern: 'example.com', custom_domain: true } + * ] + * }) + * ``` + */ +export interface DevflareConfigInput { + /** + * Worker name used for local service identity, generated Wrangler config, + * and deploy targets. + * + * @example + * ```ts + * name: 'my-worker' + * ``` + */ + name: string + + /** + * Cloudflare account ID used when resolving account-backed resources such + * as D1 databases, KV namespaces, R2 buckets, AI, Vectorize, and Hyperdrive. + * + * @example + * ```ts + * accountId: '023e105f4ecef8ad9ca31a8372d0c353' + * ``` + */ + accountId?: string + + /** + * Default Cloudflare Secrets Store ID for shorthand + * `bindings.secretsStore` entries. + * + * @example + * ```ts + * secretsStoreId: 'secrets-store-uuid' + * ``` + */ + secretsStoreId?: string + + /** + * Cloudflare Workers compatibility date in `YYYY-MM-DD` format. + * + * @default Current date when Devflare parses the config. + * + * @example + * ```ts + * compatibilityDate: '2026-05-01' + * ``` + */ + compatibilityDate?: string + + /** + * Additional Workers compatibility flags. Devflare always includes + * `nodejs_compat` and `nodejs_als` after normalization. + * + * @default `['nodejs_compat', 'nodejs_als']` + * + * @example + * ```ts + * compatibilityFlags: ['global_fetch_strictly_public'] + * ``` + */ + compatibilityFlags?: string[] + + /** + * Preview-specific Devflare behavior. + * + * @default `{ includeCrons: false }` + * + * @example + * ```ts + * previews: { includeCrons: true } + * ``` + */ + previews?: PreviewConfigInput + + /** + * Source file discovery for Worker handlers, Durable Objects, entrypoints, + * workflows, route files, and the internal transport worker. + * + * @default Devflare auto-discovers conventional `src/*` handler files. + * + * @example + * ```ts + * files: { + * fetch: 'src/fetch.ts', + * queue: false, + * entrypoints: 'src/ep.*.ts' + * } + * ``` + */ + files?: FilesConfigInput + + /** + * Cloudflare service bindings exposed on `env`, including KV, D1, R2, + * Durable Objects, Queues, service bindings, Hyperdrive, AI, and related + * platform resources. + * + * @example + * ```ts + * bindings: { + * kv: { CACHE: 'my-cache' }, + * services: { API: apiWorker.worker('ApiEntrypoint') } + * } + * ``` + */ + bindings?: BindingsConfigInput + + /** + * Scheduled trigger configuration for cron events. + * + * @example + * ```ts + * triggers: { + * crons: ['0,15,30,45 * * * *'] + * } + * ``` + */ + triggers?: TriggersConfigInput + + /** + * Wrangler module rules for non-JavaScript imports and additional modules. + * + * @example + * ```ts + * rules: [ + * { type: 'Text', globs: ['content/*.txt'] } + * ] + * ``` + */ + rules?: ModuleRuleConfigInput[] + + /** + * Whether Wrangler should include additional files matching configured + * module rules. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * findAdditionalModules: true + * ``` + */ + findAdditionalModules?: boolean + + /** + * Base directory used by Wrangler module rule discovery. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * baseDir: './src' + * ``` + */ + baseDir?: string + + /** + * Whether Wrangler should preserve emitted file names for bundled modules. + * + * @default Wrangler default behavior. + * + * @example + * ```ts + * preserveFileNames: true + * ``` + */ + preserveFileNames?: boolean + + /** + * Tail Workers that receive traces emitted by this Worker. + * + * @example + * ```ts + * tailConsumers: ['trace-worker'] + * ``` + */ + tailConsumers?: TailConsumerConfigInput[] + + /** + * Runtime variables exposed on `env` and on the typed `vars` helper. + * Values may be literals, nested objects, or `env.NAME` descriptors that + * Devflare resolves from `.env` / `.env.dev` files and `process.env`. + * + * @example + * ```ts + * import { env } from 'devflare/config' + * + * vars: { + * APP_ENV: 'development', + * API_ORIGIN: 'https://api.example.com', + * mongo: { + * uri: env.MONGOURI, + * database: env.MONGODATABASE + * }, + * retries: env.RETRIES.parse(Number) + * } + * ``` + */ + vars?: DevflareVarsInput + + /** + * Secret bindings that Devflare validates and emits into generated config. + * Values are declarations, not the secret values themselves. + * + * @example + * ```ts + * secrets: { + * STRIPE_SECRET_KEY: { required: true } + * } + * ``` + */ + secrets?: Record + + /** + * Cloudflare deployment routes for the Worker. + * + * @example + * ```ts + * routes: [ + * { pattern: 'api.example.com', custom_domain: true } + * ] + * ``` + */ + routes?: RouteConfigInput[] + + /** + * Local development WebSocket routes that proxy requests to Durable + * Objects. + * + * @example + * ```ts + * wsRoutes: [ + * { pattern: '/rooms/:id', doNamespace: 'ROOM' } + * ] + * ``` + */ + wsRoutes?: WsRouteConfigInput[] + + /** + * Static assets configuration compiled to Wrangler's assets settings. + * + * @example + * ```ts + * assets: { + * directory: './dist', + * not_found_handling: 'single-page-application' + * } + * ``` + */ + assets?: AssetsConfigInput + + /** + * Cloudflare Containers launched alongside the Worker. + * + * @example + * ```ts + * containers: [ + * { className: 'RendererContainer', image: './Dockerfile' } + * ] + * ``` + */ + containers?: ContainerConfigInput[] + + /** + * Worker placement configuration for Smart Placement or targeted + * placement. + * + * @example + * ```ts + * placement: { mode: 'smart', hint: 'wnam' } + * ``` + */ + placement?: PlacementConfigInput + + /** + * Worker resource limits. + * + * @default Cloudflare Workers account and plan defaults. + * + * @example + * ```ts + * limits: { cpu_ms: 50 } + * ``` + */ + limits?: LimitsConfigInput + + /** + * Observability settings for Worker logs and traces. + * + * @example + * ```ts + * observability: { + * enabled: true, + * head_sampling_rate: 0.1 + * } + * ``` + */ + observability?: ObservabilityConfigInput + + /** + * Durable Object migration declarations. + * + * @example + * ```ts + * migrations: [ + * { tag: 'v1', new_sqlite_classes: ['Counter'] } + * ] + * ``` + */ + migrations?: MigrationConfigInput[] + + /** + * Rolldown options used by Devflare's Durable Object bundler. + * + * @example + * ```ts + * rolldown: { + * target: 'es2022', + * sourcemap: true + * } + * ``` + */ + rolldown?: RolldownConfigInput + + /** + * Devflare's Vite-related configuration namespace. Raw Vite build and + * server configuration still belongs in `vite.config.ts`. + * + * @example + * ```ts + * vite: { + * plugins: [] + * } + * ``` + */ + vite?: ViteConfigInput + + /** + * Wrangler passthrough for options Devflare does not model directly yet. + * + * @example + * ```ts + * wrangler: { + * passthrough: { + * main: '.svelte-kit/cloudflare/_worker.js' + * } + * } + * ``` + */ + wrangler?: WranglerConfigInput + + /** + * Environment-specific overrides keyed by environment name. Environment + * overrides inherit root config and can override only the fields they need. + * + * @example + * ```ts + * env: { + * production: { + * vars: { APP_ENV: 'production' } + * } + * } + * ``` + */ + env?: Record +} + +/** + * Environment-specific config override input. All root fields are optional + * inside an environment, except fields that do not make sense per environment. + */ +export interface DevflareEnvConfigInput extends Partial> {} + +export type * from './schema-types-bindings' +export type * from './schema-types-build' +export type * from './schema-types-runtime' diff --git a/packages/devflare/src/config/schema.ts b/packages/devflare/src/config/schema.ts new file mode 100644 index 0000000..2c5baf6 --- /dev/null +++ b/packages/devflare/src/config/schema.ts @@ -0,0 +1,360 @@ +// ============================================================================= +// Config Schema โ€” Zod schema for devflare.config.ts validation +// ============================================================================= +// +// This module assembles the complete schema for devflare configuration files. +// Leaf schema modules live beside it so the public API stays stable without +// keeping every schema, transform, and utility in one giant file. +// +// DEFAULTS (you don't need to specify these): +// - compatibilityDate: Defaults to current date (YYYY-MM-DD) +// - compatibilityFlags: Always includes ['nodejs_compat', 'nodejs_als'] +// +// ============================================================================= + +import { z } from 'zod' +import { normalizeCompatibilityFlags } from './compatibility' +import { + rolldownConfigSchema, + viteConfigSchema +} from './schema-build' +import { bindingsSchema } from './schema-bindings' +import { envConfigSchemaInner } from './schema-env' +import { isEnvVarDescriptor } from './env-vars' +import { + assetsConfigSchema, + compatibilityDateSchema, + containersConfigSchema, + filesSchema, + limitsSchema, + migrationSchema, + moduleRulesSchema, + observabilitySchema, + placementSchema, + previewsConfigSchema, + routeConfigSchema, + secretConfigSchema, + tailConsumerSchema, + triggersSchema, + wranglerConfigSchema, + wsRouteConfigSchema +} from './schema-runtime' + +/** Helper to get current date in YYYY-MM-DD format */ +function getCurrentDate(): string { + const now = new Date() + return now.toISOString().split('T')[0] +} + +function getSecretsStoreShorthandBindings(config: { + bindings?: { + secretsStore?: Record + } +}): string[] { + return Object.entries(config.bindings?.secretsStore ?? {}) + .filter(([, binding]) => typeof binding === 'string') + .map(([bindingName]) => bindingName) +} + +function addSecretsStoreShorthandIssues( + ctx: z.RefinementCtx, + config: { + secretsStoreId?: string + bindings?: { + secretsStore?: Record + } + }, + pathPrefix: Array = [] +): void { + if (config.secretsStoreId) { + return + } + + for (const bindingName of getSecretsStoreShorthandBindings(config)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...pathPrefix, 'bindings', 'secretsStore', bindingName], + message: + `Secrets Store binding "${bindingName}" uses shorthand and requires top-level secretsStoreId.` + }) + } +} + +const varValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.custom(isEnvVarDescriptor), + z.array(varValueSchema), + z.record(z.string(), varValueSchema) + ]) +) + +/** + * Raw Zod shape of the root devflare configuration (excluding the `env` field, + * which references back into this shape via the environment override schema). + * + * Exported so `schema-env.ts` can derive the environment override schema from + * the single source of truth without hand-listing every field. + */ +export const rootConfigShape = { + /** + * Worker name (required). + * Used as the deployment target and in URLs. + */ + name: z.string({ + required_error: 'Worker name is required' + }), + + /** + * Cloudflare account ID. + * Required for remote bindings (AI, Vectorize, etc.). + */ + accountId: z.string().optional(), + + /** + * Default Cloudflare Secrets Store ID used by shorthand Secrets Store + * bindings in `bindings.secretsStore`. + */ + secretsStoreId: z.string().min(1).optional(), + + /** + * Cloudflare Workers compatibility date. + * @default Current date (YYYY-MM-DD) + */ + compatibilityDate: compatibilityDateSchema.optional().default(getCurrentDate), + + /** + * Compatibility flags to enable additional features. + * @default ['nodejs_compat', 'nodejs_als'] (always included) + */ + compatibilityFlags: z.array(z.string()).optional().transform((flags = []) => normalizeCompatibilityFlags(flags)), + + /** Preview-specific Devflare behavior. */ + previews: previewsConfigSchema, + + /** File handlers configuration. */ + files: filesSchema, + + /** Bindings to Cloudflare services. */ + bindings: bindingsSchema, + + /** Trigger configuration (cron schedules). */ + triggers: triggersSchema, + + /** Wrangler module rules for non-JavaScript imports and additional modules. */ + rules: moduleRulesSchema, + + /** Whether Wrangler should include additional files matching module rules. */ + findAdditionalModules: z.boolean().optional(), + + /** Base directory for Wrangler module rule discovery. */ + baseDir: z.string().optional(), + + /** Whether Wrangler should preserve bundled file names. */ + preserveFileNames: z.boolean().optional(), + + /** Tail Workers that consume traces from this Worker. */ + tailConsumers: z.array(tailConsumerSchema).optional(), + + /** Environment variables. */ + vars: z.record(z.string(), varValueSchema).optional(), + + /** Secret declarations. */ + secrets: z.record(z.string(), secretConfigSchema).optional(), + + /** Deployment routes. */ + routes: z.array(routeConfigSchema).optional(), + + /** WebSocket routes for dev mode DO proxying. */ + wsRoutes: z.array(wsRouteConfigSchema).optional(), + + /** Static assets configuration. */ + assets: assetsConfigSchema, + + /** Cloudflare Containers launched alongside the Worker. */ + containers: containersConfigSchema, + + /** Worker placement behavior. */ + placement: placementSchema, + + /** Resource limits. */ + limits: limitsSchema, + + /** Observability settings (logging, tracing). */ + observability: observabilitySchema, + + /** Durable Object migrations. */ + migrations: z.array(migrationSchema).optional(), + + /** Rolldown configuration for Durable Object bundling. */ + rolldown: rolldownConfigSchema, + + /** Vite-related configuration namespace. */ + vite: viteConfigSchema, + + /** Wrangler passthrough for unsupported options. */ + wrangler: wranglerConfigSchema +} as const + +/** + * Main devflare configuration schema. + * + * This is the complete schema for `devflare.config.ts` files. + * Use `defineConfig()` for type-safe configuration with autocompletion. + */ +const canonicalConfigSchema = z.object({ + ...rootConfigShape, + /** Environment-specific configuration overrides. */ + env: z.record(z.string(), envConfigSchemaInner).optional() +}).strict().superRefine((config, ctx) => { + addSecretsStoreShorthandIssues(ctx, config) + + for (const [envName, envConfig] of Object.entries(config.env ?? {})) { + addSecretsStoreShorthandIssues(ctx, { + ...envConfig, + secretsStoreId: envConfig.secretsStoreId ?? config.secretsStoreId + }, ['env', envName]) + } +}) + +export const configSchema = canonicalConfigSchema + +/** Output type after Zod validation and transforms */ +export type DevflareConfig = z.output + +export type { + AiBindingInput, + AiSearchInstanceBindingInput, + AiSearchNamespaceBindingInput, + AnalyticsBindingInput, + ArtifactsBindingInput, + ArtifactsBindingObjectInput, + AssetsConfigInput, + BindingsConfigInput, + BrowserBindingInput, + BrowserBindingObjectInput, + ContainerConfigInput, + D1BindingByIdInput, + D1BindingByNameInput, + D1BindingInput, + DevflareConfigInput, + DevflareEnvConfigInput, + DispatchNamespaceBindingInput, + DispatchNamespaceBindingObjectInput, + DispatchNamespaceOutboundInput, + DurableObjectBindingInput, + DurableObjectBindingObjectInput, + FilesConfigInput, + HyperdriveBindingByIdInput, + HyperdriveBindingByNameInput, + HyperdriveBindingInput, + ImagesBindingInput, + ImagesBindingObjectInput, + KVBindingByIdInput, + KVBindingByNameInput, + KVBindingInput, + LimitsConfigInput, + MediaBindingInput, + MediaBindingObjectInput, + MigrationConfigInput, + ModuleRuleConfigInput, + MtlsCertificateBindingByIdInput, + MtlsCertificateBindingByWranglerIdInput, + MtlsCertificateBindingInput, + ObservabilityConfigInput, + ObservabilityLogsConfigInput, + ObservabilityTracesConfigInput, + PipelineBindingInput, + PipelineBindingObjectInput, + PlacementConfigInput, + PreviewConfigInput, + QueueConsumerInput, + QueuesConfigInput, + RateLimitBindingInput, + RateLimitSimpleInput, + RenamedClassMigrationInput, + RolldownConfigInput, + RouteConfigInput, + RouteTreeConfigInput, + SecretConfigInput, + SecretsStoreBindingInput, + SecretsStoreBindingObjectInput, + SendEmailBindingInput, + ServiceBindingInput, + SmartPlacementConfigInput, + TailConsumerConfigInput, + TailConsumerObjectConfigInput, + TargetedHostPlacementConfigInput, + TargetedHostnamePlacementConfigInput, + TargetedRegionPlacementConfigInput, + TriggersConfigInput, + VectorizeBindingInput, + VersionMetadataBindingInput, + ViteConfigInput, + WorkflowBindingInput, + WorkflowLimitsInput, + WorkerLoaderBindingInput, + WranglerConfigInput, + WsRouteConfigInput +} from './schema-types' + +export type { DevflareRolldownOptions, DevflareRolldownOutputOptions, RolldownConfig, ViteConfig } from './schema-build' +export type { + BrowserBindings, + D1Binding, + DurableObjectBinding, + HyperdriveBinding, + KVBinding, + QueueConsumer, + QueuesConfig, + RateLimitBinding, + VersionMetadataBinding, + WorkerLoaderBinding, + SecretsStoreBinding, + DispatchNamespaceBinding, + WorkflowBinding, + PipelineBinding, + ImagesBinding, + MediaBinding, + ArtifactsBinding, + ServiceBinding, + MtlsCertificateBinding +} from './schema-bindings' +export type { DevflareEnvConfig } from './schema-env' +export type { AssetsConfig, ContainerConfig, MigrationConfig, ModuleRuleConfig, PlacementConfig, PreviewConfig, RouteConfig, TailConsumerConfig, WsRouteConfig } from './schema-runtime' +export type { + NormalizedD1Binding, + NormalizedDispatchNamespaceBinding, + NormalizedDOBinding, + NormalizedHyperdriveBinding, + NormalizedKVBinding, + NormalizedMtlsCertificateBinding, + NormalizedWorkflowBinding, + NormalizedPipelineBinding, + NormalizedImagesBinding, + NormalizedMediaBinding, + NormalizedArtifactsBinding, + NormalizedSecretsStoreBinding +} from './schema-normalization' +export { + getLocalD1DatabaseIdentifier, + getLocalHyperdriveConfigIdentifier, + getLocalKVNamespaceIdentifier, + getSingleBrowserBindingName, + normalizeD1Binding, + normalizeDispatchNamespaceBinding, + normalizeDOBinding, + normalizeHyperdriveBinding, + normalizeKVBinding, + normalizeMtlsCertificateBinding, + normalizeWorkflowBinding, + normalizePipelineBinding, + normalizeImagesBinding, + normalizeMediaBinding, + normalizeSecretsStoreBinding, + normalizeArtifactsBinding +} from './schema-normalization' +export { browserBindingSchema, formatBrowserBindingLimitMessage } from './schema-bindings' diff --git a/packages/devflare/src/config/service-bindings-validation.ts b/packages/devflare/src/config/service-bindings-validation.ts new file mode 100644 index 0000000..121759a --- /dev/null +++ b/packages/devflare/src/config/service-bindings-validation.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Service binding validation (C16 fix, R3 scope) +// ============================================================================= +// Cloudflare `services.X.service` bindings are only validated at runtime: +// a typo compiles and deploys, then the worker fails the first time it +// dispatches to the nonexistent service. This helper surfaces the error at +// deploy time by listing the account's workers and asserting that every +// referenced service name exists. +// +// Intended callers: the deploy CLI (`deploy.ts`) and preview-scope deploy +// paths (`preview-resources.ts`). The helper is deliberately NOT wired into +// the resolver chain - validation is a deploy-phase concern and running it +// during `vite build` / local dev would require Cloudflare credentials for +// offline work. +// ============================================================================= + +import type { DevflareConfig } from './schema' + +export class ServiceBindingValidationError extends Error { + readonly code = 'SERVICE_BINDING_VALIDATION_ERROR' + readonly missing: readonly string[] + + constructor(missing: readonly string[], accountId: string) { + super( + `Service binding(s) reference worker(s) that do not exist in Cloudflare account ${accountId}: ` + + missing.join(', ') + + `. Check the 'services' map in devflare.config.ts for typos or deploy the target worker(s) first.` + ) + this.name = 'ServiceBindingValidationError' + this.missing = missing + } +} + +export interface ValidateServiceBindingsOptions { + /** + * Lists workers in the target Cloudflare account. Must return one entry + * per deployed worker script with its `name`. + */ + listWorkers: (accountId: string) => Promise> + /** + * Name of the worker currently being deployed. A service binding back + * to the same worker is allowed even if the worker has never been + * deployed before (first deploy self-reference). + */ + selfWorkerName?: string +} + +/** + * Collect every `service` target referenced by the config's `bindings.services` + * map, deduplicated. Returns `[]` when no service bindings are configured. + */ +export function collectReferencedServiceNames(config: DevflareConfig): string[] { + const services = config.bindings?.services + if (!services) { + return [] + } + + const names = new Set() + for (const binding of Object.values(services)) { + if (binding && typeof binding === 'object' && typeof (binding as { service?: unknown }).service === 'string') { + const name = (binding as { service: string }).service.trim() + if (name.length > 0) { + names.add(name) + } + } + } + return [...names] +} + +/** + * Validate that every service binding target exists in the Cloudflare account. + * + * Throws `ServiceBindingValidationError` with the full list of missing + * targets if any are unreachable. A missing self-reference is tolerated + * when `selfWorkerName` matches, so first deploys don't fail against + * themselves. + */ +export async function validateServiceBindings( + config: DevflareConfig, + accountId: string, + options: ValidateServiceBindingsOptions +): Promise { + const referenced = collectReferencedServiceNames(config) + if (referenced.length === 0) { + return + } + + const selfName = options.selfWorkerName?.trim() + const toValidate = selfName + ? referenced.filter((name) => name !== selfName) + : referenced + + if (toValidate.length === 0) { + return + } + + const workers = await options.listWorkers(accountId) + const workerNames = new Set(workers.map((worker) => worker.name)) + + const missing = toValidate.filter((name) => !workerNames.has(name)) + if (missing.length > 0) { + throw new ServiceBindingValidationError(missing, accountId) + } +} diff --git a/packages/devflare/src/decorators/durable-object.ts b/packages/devflare/src/decorators/durable-object.ts new file mode 100644 index 0000000..b762012 --- /dev/null +++ b/packages/devflare/src/decorators/durable-object.ts @@ -0,0 +1,81 @@ +/** + * Options for the @durableObject decorator + */ +export interface DurableObjectOptions { + /** + * Enable alarm handling (wraps alarm() method with context) + * @default true + */ + alarms?: boolean + + /** + * RPC method names to expose + * When specified, only these methods will be exposed via RPC + */ + rpc?: string[] + + /** + * WebSocket handling options + * @default true + */ + websockets?: boolean + + /** + * Custom class name for wrangler binding + * If not provided, uses the decorated class name + */ + className?: string +} + +/** + * Decorator factory for Durable Object classes + * + * @example + * ```ts + * // Basic usage + * @durableObject() + * export class Counter { + * private count = 0 + * + * async increment() { + * return ++this.count + * } + * } + * + * // With options + * @durableObject({ alarms: true, rpc: ['increment', 'getValue'] }) + * export class Timer { + * // ... + * } + * ``` + * + * Note: This decorator is primarily used as a marker for the Vite transform. + * At runtime, it returns the class unchanged. The actual context injection + * happens during the build transform. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyClass = abstract new (...args: any[]) => any + +export function durableObject(options: DurableObjectOptions = {}) { + return function (target: T): T { + // Store options on the class for potential runtime access + Object.defineProperty(target, '__durableObjectOptions', { + value: options, + enumerable: false, + writable: false + }) + + // Return class unchanged โ€” transform handles wrapping + return target + } +} + +/** + * Get the stored durable object options from a decorated class + */ +export function getDurableObjectOptions(target: unknown): DurableObjectOptions | undefined { + if (typeof target === 'function') { + return (target as unknown as { __durableObjectOptions?: DurableObjectOptions }).__durableObjectOptions + } + return undefined +} diff --git a/packages/devflare/src/decorators/index.ts b/packages/devflare/src/decorators/index.ts new file mode 100644 index 0000000..69454ca --- /dev/null +++ b/packages/devflare/src/decorators/index.ts @@ -0,0 +1,6 @@ +// ============================================================================= +// Decorators Index โ€” Export all decorators +// ============================================================================= + +export { durableObject, getDurableObjectOptions } from './durable-object' +export type { DurableObjectOptions } from './durable-object' diff --git a/packages/devflare/src/dev-server/d1-migrations.ts b/packages/devflare/src/dev-server/d1-migrations.ts new file mode 100644 index 0000000..fc0b557 --- /dev/null +++ b/packages/devflare/src/dev-server/d1-migrations.ts @@ -0,0 +1,231 @@ +import type { ConsolaInstance } from 'consola' +import { createHash } from 'node:crypto' +import { resolve } from 'pathe' +import type { DevflareConfig } from '../config' + +export interface RunD1MigrationsOptions { + cwd: string + config: DevflareConfig | null + miniflarePort: number + logger?: ConsolaInstance +} + +interface MigrationFile { + filename: string + sha256: string + statements: string[] +} + +interface MigrationWarning { + filename: string + message?: string +} + +interface MigrationResponse { + success?: boolean + error?: string + results?: unknown[] + applied?: string[] + skipped?: string[] + warnings?: MigrationWarning[] +} + +const MIGRATION_RETRY_DELAYS_MS = [500, 1000, 1500, 2000] as const + +function collectMigrationStatements(sql: string): string[] { + const cleanedSql = sql + .split('\n') + .filter((line: string) => !line.trim().startsWith('--')) + .join('\n') + + return cleanedSql + .split(';') + .map((statement: string) => statement.trim()) + .filter((statement: string) => statement.length > 0) +} + +function hashSql(sql: string): string { + return createHash('sha256').update(sql).digest('hex') +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function waitForRetry(delayMs: number): Promise { + await new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs)) +} + +async function applyMigrationsToBinding(options: { + bindingName: string + statements: string[] + files: MigrationFile[] + miniflarePort: number + logger?: ConsolaInstance +}): Promise { + const { bindingName, statements, files, miniflarePort, logger } = options + let lastError: unknown + + for (let attempt = 0;attempt <= MIGRATION_RETRY_DELAYS_MS.length;attempt++) { + if (attempt > 0) { + await waitForRetry(MIGRATION_RETRY_DELAYS_MS[attempt - 1]) + } + + try { + const response = await fetch(`http://127.0.0.1:${miniflarePort}/_devflare/migrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bindingName, statements, files }) + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`HTTP ${response.status}: ${text}`) + } + + const result = await response.json() as MigrationResponse + if (result.success) { + if (Array.isArray(result.warnings)) { + for (const warning of result.warnings) { + console.warn( + `[devflare] D1 migration file "${warning.filename}" for binding ${bindingName} has changed since it was applied; skipping re-apply to protect existing data.` + ) + } + } + + const appliedCount = result.applied?.length ?? 0 + const skippedCount = result.skipped?.length ?? 0 + if (appliedCount > 0 || skippedCount > 0) { + logger?.success( + `D1 migrations for ${bindingName}: ${appliedCount} applied, ${skippedCount} skipped` + ) + } else { + logger?.success(`D1 migrations applied to ${bindingName}`) + } + return + } + + throw new Error(result.error || 'Unknown error') + } catch (error) { + lastError = error + } + } + + logger?.warn(`Failed to apply migrations to ${bindingName}: ${getErrorMessage(lastError)}`) +} + +/** + * Run D1 migrations from migrations/ directory. + * + * Resolution per D1 binding (in order): + * 1. `/migrations//*.sql` โ€” per-binding directory. + * NOTE: if the per-binding directory EXISTS but contains no .sql files, + * the binding is skipped โ€” the shared fallback is NOT used. + * 2. `/migrations/*.sql` โ€” shared fallback, used ONLY when the + * per-binding directory does not exist. + * 3. Otherwise, skip the binding with a debug log. + * + * Applied-migration ledger: + * The gateway maintains a `_devflare_migrations` table per D1 binding with + * columns (filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL, + * sha256 TEXT NOT NULL). On each run the gateway, for every file we send: + * - if filename is present AND sha256 matches โ€” skip silently + * - if filename is present but sha256 differs โ€” surface a warning that + * the client turns into a `console.warn`; the file is skipped to + * protect existing data (there is no force-reapply flag today) + * - if filename is absent โ€” apply the file's SQL, then record the entry + * We pass each file (filename + sha256 + statements) in a single + * /_devflare/migrate request so ledger read/write stays in-process inside + * workerd. + * + * Uses the gateway worker HTTP endpoint to run migrations inside workerd. + */ +export async function runD1Migrations(options: RunD1MigrationsOptions): Promise { + const { cwd, config, miniflarePort, logger } = options + if (!config?.bindings?.d1) { + return + } + + const { existsSync, readdirSync, readFileSync, statSync } = await import('node:fs') + const migrationsDir = resolve(cwd, 'migrations') + + if (!existsSync(migrationsDir)) { + logger?.debug('No migrations/ directory found, skipping D1 migrations') + return + } + + const sharedFiles = readdirSync(migrationsDir) + .filter((file: string) => file.endsWith('.sql')) + .sort() + + let sharedFileEntries: MigrationFile[] | null = null + if (sharedFiles.length > 0) { + sharedFileEntries = [] + for (const file of sharedFiles) { + const sql = readFileSync(resolve(migrationsDir, file), 'utf-8') + const fileStatements = collectMigrationStatements(sql) + sharedFileEntries.push({ + filename: file, + sha256: hashSql(sql), + statements: fileStatements + }) + logger?.debug(`Shared file ${file}: ${fileStatements.length} statement(s)`) + } + } + + for (const [bindingName] of Object.entries(config.bindings.d1)) { + const perBindingDir = resolve(migrationsDir, bindingName) + const hasPerBindingDir = existsSync(perBindingDir) && statSync(perBindingDir).isDirectory() + + let files: MigrationFile[] = [] + let sourceLabel = '' + + if (hasPerBindingDir) { + const perBindingFiles = readdirSync(perBindingDir) + .filter((file: string) => file.endsWith('.sql')) + .sort() + + // An empty per-binding directory intentionally skips the binding + // โ€” the shared fallback is NOT used when an explicit directory exists. + if (perBindingFiles.length === 0) { + logger?.debug(`No SQL migration files in migrations/${bindingName}/, skipping ${bindingName}`) + continue + } + + for (const file of perBindingFiles) { + const sql = readFileSync(resolve(perBindingDir, file), 'utf-8') + const fileStatements = collectMigrationStatements(sql) + files.push({ + filename: file, + sha256: hashSql(sql), + statements: fileStatements + }) + logger?.debug(`File ${bindingName}/${file}: ${fileStatements.length} statement(s)`) + } + sourceLabel = `migrations/${bindingName}/` + } else if (sharedFileEntries !== null) { + files = sharedFileEntries + sourceLabel = 'migrations/ [shared fallback]' + } else { + logger?.debug(`No migrations found for ${bindingName}, skipping`) + continue + } + + const statements = files.flatMap((file) => file.statements) + + logger?.info(`Running ${files.length} D1 migration(s) for ${bindingName} (from ${sourceLabel})`) + + if (statements.length === 0) { + logger?.debug(`No executable D1 migration statements for ${bindingName}`) + continue + } + + await applyMigrationsToBinding({ + bindingName, + statements, + files, + miniflarePort, + logger + }) + } +} diff --git a/packages/devflare/src/dev-server/dev-server-state.ts b/packages/devflare/src/dev-server/dev-server-state.ts new file mode 100644 index 0000000..1df145d --- /dev/null +++ b/packages/devflare/src/dev-server/dev-server-state.ts @@ -0,0 +1,134 @@ +// ============================================================================= +// Dev Server โ€” explicit state container +// ============================================================================= +// Lifts the closure-scoped mutables that previously lived inside +// `createDevServer()` into an explicit `DevServerState` object, plus a +// matching `disposeDevServerState()` that mirrors the `stop()` shutdown +// ordering. Holding this state out-of-line gives `createDevServer()` a real +// boundary between "what's running" (state) and "how to drive it" (hooks). +// ============================================================================= + +import type { BrowserShim } from '../browser-shim' +import { isIgnorableMiniflareDisposeError } from '../bridge/miniflare' +import type { DOBundler, DOBundleResult } from '../bundler' +import type { DevflareConfig } from '../config' +import type { resolveServiceBindings } from '../test/resolve-service-bindings' +import type { RouteDiscoveryResult } from '../worker-entry/routes' +import type { Miniflare as MiniflareType } from 'miniflare' +import { clearLocalSendEmailBindings } from '../utils/send-email' +import { stopSpawnedProcessTree } from './vite-utils' +import type { WorkerSurfacePaths } from './worker-surface-paths' + +/** + * All mutable handles owned by a single `createDevServer()` call. + * + * Anything that the orchestration layer (`start`/`stop`/watcher hooks) needs + * to reach across closures lives here. Pure helpers (e.g. config loaders, + * watcher diff, Miniflare config builders) keep taking explicit arguments + * instead of reading this object directly. + */ +export interface DevServerState { + enableVite: boolean + miniflare: MiniflareType | null + doBundler: DOBundler | null + workerSourceWatcher: import('chokidar').FSWatcher | null + workerWatchTargets: string[] + viteProcess: import('node:child_process').ChildProcess | null + config: DevflareConfig | null + serviceBindingResolution: Awaited> | null + browserShim: BrowserShim | null + browserShimPort: number + mainWorkerSurfacePaths: WorkerSurfacePaths + resolvedWorkerConfigPath: string | null + mainWorkerScriptPath: string | null + bundledMainWorkerScriptPath: string | null + workflowEntrypointScript: string + currentDoResult: DOBundleResult | null + mainWorkerRoutes: RouteDiscoveryResult | null + generatedViteConfigPath: string | null +} + +/** + * Build a fresh `DevServerState` with all handles in their not-yet-started + * positions. `enableVite` is the user's request โ€” `start()` may downgrade it + * later via `resolveViteIntegration`. + */ +export function createDevServerState(initial: { + enableVite: boolean + browserShimPort?: number +}): DevServerState { + return { + enableVite: initial.enableVite, + miniflare: null, + doBundler: null, + workerSourceWatcher: null, + workerWatchTargets: [], + viteProcess: null, + config: null, + serviceBindingResolution: null, + browserShim: null, + browserShimPort: initial.browserShimPort ?? 8788, + mainWorkerSurfacePaths: { + fetch: null, + queue: null, + scheduled: null, + email: null, + tail: null + }, + resolvedWorkerConfigPath: null, + mainWorkerScriptPath: null, + bundledMainWorkerScriptPath: null, + workflowEntrypointScript: '', + currentDoResult: null, + mainWorkerRoutes: null, + generatedViteConfigPath: null + } +} + +/** + * Tear down everything in `state` in the same order the legacy `stop()` + * function used. After this returns, every handle on `state` is `null` again + * and the local sendEmail bindings have been cleared. + * + * Order is important and intentionally mirrors the production sequence: + * 1. DO bundler (stop rebuilds before Miniflare goes away) + * 2. Worker source watcher (stop FS callbacks before Miniflare disposal) + * 3. Miniflare itself + * 4. Vite child process (after Miniflare so requests cannot race shutdown) + * 5. Browser shim + * 6. Local sendEmail registry reset + */ +export async function disposeDevServerState(state: DevServerState): Promise { + if (state.doBundler) { + await state.doBundler.close() + state.doBundler = null + } + + if (state.workerSourceWatcher) { + await state.workerSourceWatcher.close() + state.workerSourceWatcher = null + } + + if (state.miniflare) { + try { + await state.miniflare.dispose() + } catch (error) { + if (!isIgnorableMiniflareDisposeError(error)) { + throw error + } + } + state.miniflare = null + } + + if (state.viteProcess) { + await stopSpawnedProcessTree(state.viteProcess) + state.viteProcess = null + } + + if (state.browserShim) { + await state.browserShim.stop() + state.browserShim = null + } + + clearLocalSendEmailBindings() +} diff --git a/packages/devflare/src/dev-server/gateway-script.ts b/packages/devflare/src/dev-server/gateway-script.ts new file mode 100644 index 0000000..648c017 --- /dev/null +++ b/packages/devflare/src/dev-server/gateway-script.ts @@ -0,0 +1,282 @@ +import type { WsRouteConfig } from '../config' +import { GATEWAY_RUNTIME_JS } from '../bridge/gateway-runtime' + +/** + * Generates the dev-server gateway worker script inline. + * + * All in-sandbox RPC behavior (method dispatch, error envelope, serialization, + * WebSocket bridge, HTTP transfer) lives in `GATEWAY_RUNTIME_JS` and is shared + * with `src/bridge/miniflare.ts`. The canonical TypeScript equivalent lives in + * `src/bridge/server.ts`. + * + * This file only owns the pieces that are genuinely dev-server-specific: + * - WebSocket route matching & DO WebSocket forwarding (`WS_ROUTES`) + * - D1 migration endpoint + * - Inbound email ingestion endpoint + * - Service-binding fallthrough to the app worker + * + * @param wsRoutes - WebSocket routes for DO proxying + * @param debug - Enable debug logging in gateway + * @param appServiceBindingName - Service binding name for the app worker (if any) + */ +export function getGatewayScript( + wsRoutes: WsRouteConfig[] = [], + debug = false, + appServiceBindingName: string | null = null +): string { + const wsRoutesJson = JSON.stringify(wsRoutes) + const appServiceBindingJson = JSON.stringify(appServiceBindingName) + + return ` +${GATEWAY_RUNTIME_JS} + +// Bridge Gateway Worker โ€” Dev Server +// Dev-server-specific overlay on top of the shared GATEWAY_RUNTIME_JS: +// WS route DO forwarding, D1 migration, email ingest, app-worker fallthrough. + +const DEBUG = ${debug} +const log = (...args) => DEBUG && console.log('[Gateway]', ...args) + +const WS_ROUTES = ${wsRoutesJson} +const APP_SERVICE_BINDING = ${appServiceBindingJson} + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url) + const isWebSocket = request.headers.get('Upgrade') === 'websocket' + + if (isWebSocket) { + const matchedRoute = matchWsRoute(url.pathname) + if (matchedRoute) { + return handleDoWebSocket(request, env, url, matchedRoute) + } + return handleBridgeWebSocket(request, env, ctx) + } + + if (url.pathname.startsWith('/_devflare/transfer/')) { + return handleHttpTransfer(request, env, url) + } + + if (url.pathname === '/_devflare/migrate' && request.method === 'POST') { + return handleMigration(request, env) + } + + if (url.pathname === '/cdn-cgi/handler/email' && request.method === 'POST') { + return handleEmailIncoming(request, env, ctx, url) + } + + if (url.pathname === '/_devflare/health') { + return new Response(JSON.stringify({ + ok: true, + bindings: Object.keys(env), + wsRoutes: WS_ROUTES + }), { headers: { 'Content-Type': 'application/json' } }) + } + + if (APP_SERVICE_BINDING) { + const appWorker = env[APP_SERVICE_BINDING] + if (appWorker && typeof appWorker.fetch === 'function') { + return appWorker.fetch(request) + } + } + + return new Response('Devflare Bridge Gateway', { status: 200 }) + } +} + +async function handleMigration(request, env) { + try { + const { bindingName, statements, files } = await request.json() + log('Migration request for binding:', bindingName, 'statements count:', statements?.length, 'files:', files?.length, 'bindings:', Object.keys(env)) + const db = env[bindingName] + if (!db) { + return Response.json({ error: 'Binding not found: ' + bindingName }, { status: 404 }) + } + + // Ledger-aware path: when the client sends per-file metadata, we track + // applied migrations in a \`_devflare_migrations\` table and skip files + // whose filename+sha256 is already recorded. Files with a drifting hash + // are reported as warnings and skipped โ€” we refuse to re-apply to avoid + // stomping on user data. + if (Array.isArray(files) && files.length > 0) { + try { + await db.prepare( + 'CREATE TABLE IF NOT EXISTS _devflare_migrations (filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL, sha256 TEXT NOT NULL)' + ).run() + } catch (error) { + const msg = error?.message || String(error) + log('Failed to ensure migration ledger:', msg) + return Response.json({ error: 'Failed to ensure migration ledger: ' + msg }, { status: 500 }) + } + + let ledgerRows = [] + try { + const ledger = await db.prepare('SELECT filename, sha256 FROM _devflare_migrations').all() + ledgerRows = ledger?.results || [] + } catch (error) { + log('Failed to read migration ledger:', error?.message || String(error)) + } + const ledgerByFilename = new Map() + for (const row of ledgerRows) { + ledgerByFilename.set(row.filename, row.sha256) + } + + const applied = [] + const skipped = [] + const warnings = [] + const results = [] + + for (const file of files) { + const existingHash = ledgerByFilename.get(file.filename) + if (existingHash === file.sha256) { + skipped.push(file.filename) + continue + } + if (existingHash && existingHash !== file.sha256) { + warnings.push({ + filename: file.filename, + message: 'sha256 drifted since last apply; skipped' + }) + skipped.push(file.filename) + continue + } + + let fileFailed = false + for (const sql of file.statements || []) { + try { + log('Running migration SQL:', sql.slice(0, 80)) + await db.prepare(sql).run() + results.push({ sql: sql.slice(0, 50), success: true }) + } catch (error) { + const msg = error?.message || String(error) + log('Migration SQL error:', msg) + if (msg.includes('already exists')) { + results.push({ sql: sql.slice(0, 50), success: true, skipped: true }) + } else { + results.push({ sql: sql.slice(0, 50), success: false, error: msg }) + fileFailed = true + } + } + } + + if (!fileFailed) { + try { + await db.prepare( + 'INSERT OR REPLACE INTO _devflare_migrations (filename, applied_at, sha256) VALUES (?, ?, ?)' + ).bind(file.filename, new Date().toISOString(), file.sha256).run() + applied.push(file.filename) + } catch (error) { + log('Failed to record migration in ledger:', error?.message || String(error)) + } + } + } + + return Response.json({ success: true, results, applied, skipped, warnings }) + } + + // Legacy path: flat statement list, no ledger tracking. + const results = [] + for (const sql of statements) { + try { + log('Running migration SQL:', sql.slice(0, 80)) + await db.prepare(sql).run() + results.push({ sql: sql.slice(0, 50), success: true }) + log('Migration SQL succeeded') + } catch (error) { + const msg = error?.message || String(error) + log('Migration SQL error:', msg) + if (msg.includes('already exists')) { + results.push({ sql: sql.slice(0, 50), success: true, skipped: true }) + } else { + results.push({ sql: sql.slice(0, 50), success: false, error: msg }) + } + } + } + + try { + const tables = await db.prepare(\"SELECT name FROM sqlite_master WHERE type='table'\").all() + log('Tables after migration:', JSON.stringify(tables)) + } catch (e) { + log('Error listing tables:', e.message) + } + + return Response.json({ success: true, results }) + } catch (error) { + return Response.json({ error: error?.message || String(error) }, { status: 500 }) + } +} + +async function handleEmailIncoming(request, env, ctx, url) { + try { + const from = url.searchParams.get('from') || 'unknown@example.com' + const to = url.searchParams.get('to') || 'worker@example.com' + const rawBody = await request.text() + + log('Email incoming:', { from, to, bodyLength: rawBody.length }) + + if (APP_SERVICE_BINDING) { + const appWorker = env[APP_SERVICE_BINDING] + if (appWorker && typeof appWorker.fetch === 'function') { + const response = await appWorker.fetch(new Request('http://devflare.internal/_devflare/internal/email', { + method: 'POST', + headers: { + 'x-devflare-event': 'email', + 'x-devflare-email-from': from, + 'x-devflare-email-to': to, + 'content-type': request.headers.get('content-type') || 'text/plain' + }, + body: rawBody + })) + + if (!response.ok) { + return response + } + } + } + + return new Response(JSON.stringify({ ok: true, from, to }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('[Gateway] Email handler error:', error) + return Response.json({ error: error?.message || String(error) }, { status: 500 }) + } +} + +function matchWsRoute(pathname) { + for (const route of WS_ROUTES) { + if (pathname === route.pattern || pathname.startsWith(route.pattern + '?')) { + return route + } + } + return null +} + +async function handleDoWebSocket(request, env, url, route) { + try { + const namespace = env[route.doNamespace] + if (!namespace) { + console.error('[Gateway] DO namespace not found:', route.doNamespace) + return new Response('DO namespace not found: ' + route.doNamespace, { status: 500 }) + } + + const idValue = url.searchParams.get(route.idParam) || 'default' + const doId = namespace.idFromName(idValue) + const stub = namespace.get(doId) + + const forwardUrl = new URL(route.forwardPath, url.origin) + url.searchParams.forEach((v, k) => forwardUrl.searchParams.set(k, v)) + + log('Forwarding WebSocket to DO:', route.doNamespace, 'id:', idValue, 'path:', forwardUrl.pathname) + + return stub.fetch(forwardUrl.toString(), { + method: request.method, + headers: request.headers + }) + } catch (error) { + console.error('[Gateway] Error forwarding to DO:', error) + return new Response('Error forwarding to DO: ' + error.message, { status: 500 }) + } +} +` +} diff --git a/packages/devflare/src/dev-server/index.ts b/packages/devflare/src/dev-server/index.ts new file mode 100644 index 0000000..21dfe95 --- /dev/null +++ b/packages/devflare/src/dev-server/index.ts @@ -0,0 +1,12 @@ +// ============================================================================= +// Dev Server Module โ€” Unified Dev Experience with HMR and local worker orchestration +// ============================================================================= +// Manages the dev server surface, Miniflare integration, and the supporting +// worker bundling/reload flows used during local development. +// ============================================================================= + +export { + type DevServerOptions, + type DevServer, + createDevServer +} from './server' diff --git a/packages/devflare/src/dev-server/miniflare-bindings.ts b/packages/devflare/src/dev-server/miniflare-bindings.ts new file mode 100644 index 0000000..3ac7a2b --- /dev/null +++ b/packages/devflare/src/dev-server/miniflare-bindings.ts @@ -0,0 +1,373 @@ +// ============================================================================= +// Dev server โ€” pure binding-config translators +// ============================================================================= +// Translates `DevflareConfig.bindings.queues` and `bindings.sendEmail` into +// the shapes Miniflare expects. Extracted from the long buildMiniflareConfig +// closure in server.ts so the translations are independently testable and the +// main dev-server file is easier to read. +// ============================================================================= + +import { + normalizeArtifactsBinding, + normalizeDispatchNamespaceBinding, + normalizeHyperdriveBinding, + normalizeImagesBinding, + normalizeMediaBinding, + normalizeMtlsCertificateBinding, + normalizePipelineBinding, + normalizeSecretsStoreBinding, + normalizeWorkflowBinding, + type DevflareConfig +} from '../config' + +type Bindings = NonNullable + +export function buildQueueProducers( + bindings: Bindings +): Record | undefined { + if (!bindings.queues?.producers) { + return undefined + } + + const producers: Record = {} + for (const [bindingName, queueName] of Object.entries(bindings.queues.producers)) { + producers[bindingName] = { queueName } + } + + return producers +} + +export function buildQueueConsumers( + bindings: Bindings +): Record> | undefined { + if (!bindings.queues?.consumers || bindings.queues.consumers.length === 0) { + return undefined + } + + const consumers: Record> = {} + for (const consumer of bindings.queues.consumers) { + consumers[consumer.queue] = { + ...(consumer.maxBatchSize !== undefined && { maxBatchSize: consumer.maxBatchSize }), + ...(consumer.maxBatchTimeout !== undefined && { maxBatchTimeout: consumer.maxBatchTimeout }), + ...(consumer.maxRetries !== undefined && { maxRetries: consumer.maxRetries }), + ...(consumer.deadLetterQueue && { deadLetterQueue: consumer.deadLetterQueue }), + ...(consumer.maxConcurrency !== undefined && { maxConcurrency: consumer.maxConcurrency }), + ...(consumer.retryDelay !== undefined && { retryDelay: consumer.retryDelay }) + } + } + + return consumers +} + +export function buildRateLimitsConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.rateLimits) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.rateLimits).map(([name, binding]) => [ + name, + { + simple: { + limit: binding.simple.limit, + period: binding.simple.period + } + } + ]) + ) +} + +export function buildVersionMetadataConfig( + bindings: Bindings +): string | undefined { + return bindings.versionMetadata?.binding +} + +export function buildWorkerLoadersConfig( + bindings: Bindings +): Record> | undefined { + if (!bindings.workerLoaders) { + return undefined + } + + return Object.fromEntries( + Object.keys(bindings.workerLoaders).map((bindingName) => [bindingName, {}]) + ) +} + +export function buildMtlsCertificatesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.mtlsCertificates) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.mtlsCertificates).map(([bindingName, binding]) => { + const normalized = normalizeMtlsCertificateBinding(binding) + return [ + bindingName, + { + certificate_id: normalized.certificateId + } + ] + }) + ) +} + +export function buildDispatchNamespacesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.dispatchNamespaces) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.dispatchNamespaces).map(([bindingName, binding]) => { + const normalized = normalizeDispatchNamespaceBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) +} + +export function buildWorkflowsConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.workflows) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.workflows).map(([bindingName, binding]) => { + const normalized = normalizeWorkflowBinding(binding) + return [ + bindingName, + { + name: normalized.name, + className: normalized.className, + ...(normalized.scriptName && { scriptName: normalized.scriptName }), + ...(normalized.limits && { stepLimit: normalized.limits.steps }) + } + ] + }) + ) +} + +export function buildPipelinesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.pipelines) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.pipelines).map(([bindingName, binding]) => { + const normalized = normalizePipelineBinding(binding) + return [ + bindingName, + typeof binding === 'string' + ? normalized.pipeline + : { pipeline: normalized.pipeline } + ] + }) + ) +} + +function getHyperdriveLocalConnectionString( + bindingName: string, + binding: NonNullable[string] +): string | undefined { + const cloudflareEnvName = `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_${bindingName}` + const wranglerEnvName = `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_${bindingName}` + const envValue = process.env[cloudflareEnvName] ?? process.env[wranglerEnvName] + if (envValue?.trim()) { + return envValue + } + + const normalized = normalizeHyperdriveBinding(binding) + return normalized.localConnectionString +} + +export function buildHyperdrivesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.hyperdrive) { + return undefined + } + + const hyperdrives = Object.fromEntries( + Object.entries(bindings.hyperdrive) + .map(([bindingName, binding]) => { + const localConnectionString = getHyperdriveLocalConnectionString(bindingName, binding) + return localConnectionString + ? [bindingName, localConnectionString] + : null + }) + .filter((entry): entry is [string, string] => entry !== null) + ) + + return Object.keys(hyperdrives).length > 0 ? hyperdrives : undefined +} + +export function buildImagesConfig( + bindings: Bindings +): { binding: string } | undefined { + if (!bindings.images) { + return undefined + } + + const [entry] = Object.entries(bindings.images) + if (!entry) { + return undefined + } + + const [bindingName, binding] = entry + const normalized = normalizeImagesBinding(bindingName, binding) + return { + binding: normalized.binding + } +} + +export function buildMediaConfig( + bindings: Bindings +): { binding: string } | undefined { + if (!bindings.media) { + return undefined + } + + const [entry] = Object.entries(bindings.media) + if (!entry) { + return undefined + } + + const [bindingName, binding] = entry + const normalized = normalizeMediaBinding(bindingName, binding) + return { + binding: normalized.binding + } +} + +export function buildArtifactsConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.artifacts) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.artifacts).map(([bindingName, binding]) => { + const normalized = normalizeArtifactsBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) +} + +export function buildAiSearchNamespacesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.aiSearchNamespaces) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.aiSearchNamespaces).map(([bindingName, binding]) => [ + bindingName, + { + namespace: binding.namespace + } + ]) + ) +} + +export function buildAiSearchInstancesConfig( + bindings: Bindings +): Record | undefined { + if (!bindings.aiSearch) { + return undefined + } + + return Object.fromEntries( + Object.entries(bindings.aiSearch).map(([bindingName, binding]) => [ + bindingName, + { + instance_name: binding.instanceName + } + ]) + ) +} + +export function buildSecretsStoreConfig( + bindings: Bindings, + defaultSecretsStoreId?: string, + excludedBindingNames: Set = new Set() +): Record | undefined { + if (!bindings.secretsStore) { + return undefined + } + + const entries = Object.entries(bindings.secretsStore).flatMap(([bindingName, binding]) => { + if (excludedBindingNames.has(bindingName)) { + return [] + } + + const normalized = normalizeSecretsStoreBinding(binding, defaultSecretsStoreId, bindingName) + return [[ + bindingName, + { + store_id: normalized.storeId, + secret_name: normalized.secretName + } + ]] + }) + + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +export function buildSendEmailConfig( + bindings: Bindings +): + | { + send_email: Array<{ + name: string + destination_address?: string + allowed_destination_addresses?: string[] + allowed_sender_addresses?: string[] + }> + } + | undefined { + if (!bindings.sendEmail) { + return undefined + } + + return { + send_email: Object.entries(bindings.sendEmail).map(([name, binding]) => ({ + name, + ...(binding.destinationAddress && { + destination_address: binding.destinationAddress + }), + ...(binding.allowedDestinationAddresses && { + allowed_destination_addresses: binding.allowedDestinationAddresses + }), + ...(binding.allowedSenderAddresses && { + allowed_sender_addresses: binding.allowedSenderAddresses + }) + })) + } +} diff --git a/packages/devflare/src/dev-server/miniflare-dev-config.ts b/packages/devflare/src/dev-server/miniflare-dev-config.ts new file mode 100644 index 0000000..ab08b2f --- /dev/null +++ b/packages/devflare/src/dev-server/miniflare-dev-config.ts @@ -0,0 +1,290 @@ +// ============================================================================= +// Dev Server โ€” Top-level Miniflare config orchestrator +// ============================================================================= +// Pure helper extracted from createDevServer().buildMiniflareConfig(). +// Composes gateway + main app worker + DO workers + browser-binding worker +// from explicit inputs (no closures), so the multi-worker assembly logic can +// be unit-tested independently of the dev-server lifecycle. +// ============================================================================= + +import { resolve } from 'pathe' +import type { ConsolaInstance } from 'consola' +import type { DevflareConfig } from '../config' +import { getSingleBrowserBindingName } from '../config/schema' +import type { DOBundleResult } from '../bundler' +import { getBrowserBindingScript } from '../browser-shim/binding-worker' +import type { RouteDiscoveryResult } from '../worker-entry/routes' +import { + buildQueueConsumers, + buildQueueProducers, + buildRateLimitsConfig, + buildSecretsStoreConfig, + buildSendEmailConfig, + buildVersionMetadataConfig, + buildWorkerLoadersConfig, + buildMtlsCertificatesConfig, + buildDispatchNamespacesConfig, + buildWorkflowsConfig, + buildPipelinesConfig, + buildHyperdrivesConfig, + buildImagesConfig, + buildMediaConfig, + buildArtifactsConfig, + buildAiSearchNamespacesConfig, + buildAiSearchInstancesConfig +} from './miniflare-bindings' +import { getGatewayScript } from './gateway-script' +import { + buildServiceBindings, + makeMiniflareWorker, + type MakeMiniflareWorkerContext, + type MiniflareServiceBinding +} from './miniflare-worker-config' +import { hasWorkerSurfacePaths, type WorkerSurfacePaths } from './worker-surface-paths' +import { buildLocalSecretWrappedBindingConfig } from '../secrets/local-secrets' +import { buildLocalBindingShimServiceConfig } from '../shims/local-media-bindings' +import type { resolveServiceBindings } from '../test/resolve-service-bindings' + +const INTERNAL_APP_SERVICE_BINDING = '__DEVFLARE_APP' +type ServiceBindingResolution = Awaited> + +export interface BuildMiniflareDevConfigInput { + config: DevflareConfig + cwd: string + miniflarePort: number + persist: boolean + enableVite: boolean + debug: boolean + mainWorkerSurfacePaths: WorkerSurfacePaths + mainWorkerRoutes: RouteDiscoveryResult | null + mainWorkerScriptPath: string | null + bundledMainWorkerScriptPath: string | null + workflowEntrypointScript: string + browserShimPort: number + doResult: DOBundleResult | null + serviceBindingResolution?: ServiceBindingResolution | null + logger?: ConsolaInstance +} + +/** + * Build the complete Miniflare configuration for the dev server. + * + * IMPORTANT: When using multi-worker setup, ALL workers must go in the + * `workers` array. The FIRST worker is the entrypoint and receives all + * HTTP requests. Top-level script/modules options are NOT used when + * workers array is present. + */ +export function buildMiniflareDevConfig(input: BuildMiniflareDevConfigInput): any { + const { + config: loadedConfig, + cwd, + miniflarePort, + persist, + enableVite, + debug, + mainWorkerSurfacePaths, + mainWorkerRoutes, + mainWorkerScriptPath, + bundledMainWorkerScriptPath, + workflowEntrypointScript, + browserShimPort, + doResult, + serviceBindingResolution, + logger + } = input + + const bindings = loadedConfig.bindings ?? {} + const persistPath = resolve(cwd, '.devflare/data') + const appWorkerName = loadedConfig.name + const shouldRunMainWorker = !enableVite && ( + hasWorkerSurfacePaths(mainWorkerSurfacePaths) + || Boolean(mainWorkerRoutes?.routes.length) + ) + const queueProducers = buildQueueProducers(bindings) + const queueConsumers = buildQueueConsumers(bindings) + + const sharedOptions: any = { + port: miniflarePort, + host: '127.0.0.1', + kvPersist: persist ? `${persistPath}/kv` : undefined, + r2Persist: persist ? `${persistPath}/r2` : undefined, + d1Persist: persist ? `${persistPath}/d1` : undefined, + durableObjectsPersist: persist ? `${persistPath}/do` : undefined, + workflowsPersist: persist ? `${persistPath}/workflows` : undefined, + imagesPersist: persist ? `${persistPath}/images` : undefined + } + + const localBindingShimServiceConfig = buildLocalBindingShimServiceConfig(loadedConfig) + const createServiceBindings = ( + extraBindings: Record = {} + ) => buildServiceBindings(bindings, { + ...(serviceBindingResolution?.primaryServiceBindings ?? {}), + ...localBindingShimServiceConfig.serviceBindings, + ...extraBindings + }) + + const sendEmailConfig = buildSendEmailConfig(bindings) + const rateLimitsConfig = buildRateLimitsConfig(bindings) + const versionMetadataConfig = buildVersionMetadataConfig(bindings) + const workerLoadersConfig = buildWorkerLoadersConfig(bindings) + const mtlsCertificatesConfig = buildMtlsCertificatesConfig(bindings) + const dispatchNamespacesConfig = buildDispatchNamespacesConfig(bindings) + const workflowsConfig = buildWorkflowsConfig(bindings) + const pipelinesConfig = buildPipelinesConfig(bindings) + const hyperdrivesConfig = buildHyperdrivesConfig(bindings) + const imagesConfig = bindings.images ? undefined : buildImagesConfig(bindings) + const mediaConfig = bindings.media ? undefined : buildMediaConfig(bindings) + const artifactsConfig = buildArtifactsConfig(bindings) + const aiSearchNamespacesConfig = buildAiSearchNamespacesConfig(bindings) + const aiSearchInstancesConfig = buildAiSearchInstancesConfig(bindings) + const localSecretWrappedBindingConfig = buildLocalSecretWrappedBindingConfig(loadedConfig, cwd) + const localSecretBindingNames = new Set(localSecretWrappedBindingConfig.localBindingNames) + const secretsStoreConfig = buildSecretsStoreConfig( + bindings, + loadedConfig.secretsStoreId, + localSecretBindingNames + ) + + const workerContext: MakeMiniflareWorkerContext = { + cwd, + loadedConfig, + bindings, + sendEmailConfig, + rateLimitsConfig, + versionMetadataConfig, + workerLoadersConfig, + mtlsCertificatesConfig, + dispatchNamespacesConfig, + workflowsConfig, + pipelinesConfig, + hyperdrivesConfig, + imagesConfig, + mediaConfig, + artifactsConfig, + aiSearchNamespacesConfig, + aiSearchInstancesConfig, + secretsStoreConfig, + localSecretWrappedBindingConfig, + queueProducers + } + + const createWorkerConfig = (options: Parameters[1]) => + makeMiniflareWorker(workerContext, options) + + const gatewayWorker = createWorkerConfig({ + name: 'gateway', + script: [ + workflowEntrypointScript, + getGatewayScript( + loadedConfig.wsRoutes, + debug, + shouldRunMainWorker ? INTERNAL_APP_SERVICE_BINDING : null + ) + ].filter(Boolean).join('\n\n'), + serviceBindings: shouldRunMainWorker + ? createServiceBindings({ + [INTERNAL_APP_SERVICE_BINDING]: { name: appWorkerName } + }) + : createServiceBindings() + }) + gatewayWorker.routes = ['*'] + + const hasDurableObjectBundles = !!doResult && doResult.bundles.size > 0 + const browserBindingName = getSingleBrowserBindingName(bindings.browser) + const needsBrowserWorker = Boolean(browserBindingName && (hasDurableObjectBundles || shouldRunMainWorker)) + + const workers: any[] = [] + const durableObjects: Record = {} + + const browserShimUrl = `http://127.0.0.1:${browserShimPort}` + const browserWorkerName = 'browser-binding' + + if (shouldRunMainWorker && mainWorkerScriptPath) { + const mainWorkerServiceBindings = createServiceBindings( + browserBindingName + ? { + [browserBindingName]: { name: browserWorkerName } + } + : {} + ) + + const mainWorkerConfig = createWorkerConfig({ + name: appWorkerName, + scriptPath: bundledMainWorkerScriptPath ?? mainWorkerScriptPath, + serviceBindings: mainWorkerServiceBindings, + queueConsumers, + triggers: loadedConfig.triggers?.crons?.length + ? { crons: loadedConfig.triggers.crons } + : undefined + }) + + workers.push(mainWorkerConfig) + } + + if (doResult) { + for (const [bindingName, bundlePath] of doResult.bundles) { + const className = doResult.classes.get(bindingName) + if (!className) continue + + const workerName = `do-${bindingName.toLowerCase()}` + + const workerConfig = createWorkerConfig({ + name: workerName, + scriptPath: bundlePath, + durableObjects: { + [bindingName]: className + }, + serviceBindings: createServiceBindings( + browserBindingName + ? { + [browserBindingName]: { name: browserWorkerName } + } + : {} + ) + }) + + if (browserBindingName) { + logger?.debug(`DO ${workerName} has browser service binding: ${browserBindingName} โ†’ ${browserWorkerName}`) + } + + logger?.debug(`DO ${workerName} config:`, JSON.stringify(workerConfig, null, 2)) + workers.push(workerConfig) + + durableObjects[bindingName] = { + className, + scriptName: workerName + } + } + } + + if (needsBrowserWorker) { + const browserWorker = createWorkerConfig({ + name: browserWorkerName, + script: getBrowserBindingScript(browserShimUrl, debug) + }) + workers.push(browserWorker) + logger?.info(`Browser binding worker configured: ${browserBindingName} โ†’ ${browserShimUrl}`) + } + + if (Object.keys(durableObjects).length > 0) { + gatewayWorker.durableObjects = durableObjects + + if (shouldRunMainWorker) { + const mainWorker = workers.find((worker) => worker.name === appWorkerName) + if (mainWorker) { + mainWorker.durableObjects = durableObjects + } + } + } + + return { + ...sharedOptions, + workers: [ + gatewayWorker, + ...workers, + ...(serviceBindingResolution?.workers ?? []), + ...localSecretWrappedBindingConfig.workers, + ...localBindingShimServiceConfig.workers + ] + } +} diff --git a/packages/devflare/src/dev-server/miniflare-log.ts b/packages/devflare/src/dev-server/miniflare-log.ts new file mode 100644 index 0000000..8c97a4f --- /dev/null +++ b/packages/devflare/src/dev-server/miniflare-log.ts @@ -0,0 +1,92 @@ +const ANSI_ESCAPE_REGEX = /\u001B\[[0-9;]*m/g + +const COMPATIBILITY_DATE_FALLBACK_REGEX = /^The latest compatibility date supported by the installed Cloudflare Workers Runtime is "([^"]+)", but you've requested "([^"]+)"\. Falling back to "([^"]+)"\.\.\.$/ + +export interface MiniflareCompatibilityLogger { + info(message: string): void +} + +const MINIFLARE_LOG_LEVEL_FALLBACKS = { + WARN: 2, + DEBUG: 4 +} as const + +interface MiniflareLogLike { + warn(message: string): void + info(message: string): void +} + +type MiniflareLogConstructor = new (level?: number) => MiniflareLogLike +type MiniflareLogLevelName = keyof typeof MINIFLARE_LOG_LEVEL_FALLBACKS +type MiniflareLogLevelExport = Partial> | undefined + +function normalizeMiniflareMessage(message: string): string { + return message + .replace(ANSI_ESCAPE_REGEX, '') + .replace(/\s+/g, ' ') + .trim() +} + +export function formatCompatibilityDateFallbackNotice(message: string): string | null { + const normalizedMessage = normalizeMiniflareMessage(message) + const match = COMPATIBILITY_DATE_FALLBACK_REGEX.exec(normalizedMessage) + + if (!match) { + return null + } + + const [, _supportedDate, requestedDate, fallbackDate] = match + return `Using latest supported Cloudflare Workers Runtime compatibility date ${fallbackDate} (requested ${requestedDate})` +} + +export function resolveMiniflareLogLevel( + logLevel: MiniflareLogLevelExport, + levelName: MiniflareLogLevelName +): number { + return logLevel?.[levelName] ?? MINIFLARE_LOG_LEVEL_FALLBACKS[levelName] +} + +export function createMiniflareLog( + BaseLog: TBase | undefined, + logLevel: MiniflareLogLevelExport, + levelName: MiniflareLogLevelName, + logger?: MiniflareCompatibilityLogger +): InstanceType | undefined { + if (!BaseLog) { + return undefined + } + + return createCompatibilityAwareMiniflareLog( + BaseLog, + resolveMiniflareLogLevel(logLevel, levelName), + logger + ) +} + +export function createCompatibilityAwareMiniflareLog( + BaseLog: TBase, + level: number, + logger?: MiniflareCompatibilityLogger +): InstanceType { + const log = new BaseLog(level) as InstanceType & MiniflareLogLike + const originalWarn = log.warn.bind(log) + const originalInfo = log.info.bind(log) + + log.warn = (message: string): void => { + const notice = formatCompatibilityDateFallbackNotice(message) + + if (!notice) { + originalWarn(message) + return + } + + if (logger) { + logger.info(notice) + return + } + + originalInfo(notice) + } + + return log as InstanceType +} diff --git a/packages/devflare/src/dev-server/miniflare-worker-config.ts b/packages/devflare/src/dev-server/miniflare-worker-config.ts new file mode 100644 index 0000000..bed91eb --- /dev/null +++ b/packages/devflare/src/dev-server/miniflare-worker-config.ts @@ -0,0 +1,235 @@ +// ============================================================================= +// Dev Server โ€” Miniflare worker-config builders +// ============================================================================= +// Pure helpers extracted from createDevServer().buildMiniflareConfig(). +// Make these explicit-input so they can be unit-tested without spinning up +// the full dev server. +// ============================================================================= + +import { resolve } from 'pathe' +import type { DevflareConfig } from '../config' +import { getLocalD1DatabaseIdentifier, getLocalKVNamespaceIdentifier } from '../config/schema' +import type { + buildRateLimitsConfig, + buildSecretsStoreConfig, + buildSendEmailConfig, + buildVersionMetadataConfig, + buildWorkerLoadersConfig, + buildMtlsCertificatesConfig, + buildDispatchNamespacesConfig, + buildWorkflowsConfig, + buildPipelinesConfig, + buildHyperdrivesConfig, + buildImagesConfig, + buildMediaConfig, + buildArtifactsConfig, + buildAiSearchNamespacesConfig, + buildAiSearchInstancesConfig +} from './miniflare-bindings' +import type { LocalSecretWrappedBindingConfig } from '../secrets/local-secrets' + +type Bindings = NonNullable +type SendEmailConfig = ReturnType +type RateLimitsConfig = ReturnType +type VersionMetadataConfig = ReturnType +type WorkerLoadersConfig = ReturnType +type MtlsCertificatesConfig = ReturnType +type DispatchNamespacesConfig = ReturnType +type WorkflowsConfig = ReturnType +type PipelinesConfig = ReturnType +type HyperdrivesConfig = ReturnType +type ImagesConfig = ReturnType +type MediaConfig = ReturnType +type ArtifactsConfig = ReturnType +type AiSearchNamespacesConfig = ReturnType +type AiSearchInstancesConfig = ReturnType +type SecretsStoreConfig = ReturnType +type ModuleRule = NonNullable[number] + +export type MiniflareServiceBinding = { name: string; entrypoint?: string } + +const DEFAULT_MODULE_RULES = [ + { type: 'ESModule', include: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.mjs'] }, + { type: 'CommonJS', include: ['**/*.js', '**/*.cjs'] }, + { type: 'ESModule', include: ['**/*.jsx'] } +] as const + +function toMiniflareModuleRule(rule: ModuleRule): { + type: ModuleRule['type'] + include: string[] + fallthrough?: boolean +} { + return { + type: rule.type, + include: rule.globs, + ...(rule.fallthrough !== undefined && { fallthrough: rule.fallthrough }) + } +} + +/** + * Build the per-worker `serviceBindings` map. Combines user-declared + * `bindings.services` (config) with any extra bindings the caller wants to + * inject (e.g. internal gateway -> app routing). + */ +export function buildServiceBindings( + bindings: Bindings, + extraBindings: Record = {} +): Record | undefined { + const serviceBindings: Record = {} + + if (bindings.services) { + for (const [bindingName, serviceConfig] of Object.entries(bindings.services)) { + serviceBindings[bindingName] = { + name: serviceConfig.service, + ...(serviceConfig.entrypoint && { entrypoint: serviceConfig.entrypoint }) + } + } + } + + for (const [bindingName, target] of Object.entries(extraBindings)) { + serviceBindings[bindingName] = target + } + + return Object.keys(serviceBindings).length > 0 ? serviceBindings : undefined +} + +export interface MakeMiniflareWorkerOptions { + name: string + script?: string + scriptPath?: string + durableObjects?: Record + serviceBindings?: Record + queueConsumers?: Record> + triggers?: { crons?: string[] } +} + +export interface MakeMiniflareWorkerContext { + cwd: string + loadedConfig: DevflareConfig + bindings: Bindings + sendEmailConfig: SendEmailConfig + rateLimitsConfig: RateLimitsConfig + versionMetadataConfig: VersionMetadataConfig + workerLoadersConfig: WorkerLoadersConfig + mtlsCertificatesConfig: MtlsCertificatesConfig + dispatchNamespacesConfig: DispatchNamespacesConfig + workflowsConfig: WorkflowsConfig + pipelinesConfig: PipelinesConfig + hyperdrivesConfig: HyperdrivesConfig + imagesConfig: ImagesConfig + mediaConfig: MediaConfig + artifactsConfig: ArtifactsConfig + aiSearchNamespacesConfig: AiSearchNamespacesConfig + aiSearchInstancesConfig: AiSearchInstancesConfig + secretsStoreConfig: SecretsStoreConfig + localSecretWrappedBindingConfig?: LocalSecretWrappedBindingConfig + queueProducers: Record | undefined +} + +/** + * Build a single worker config object for Miniflare's `workers` array. + * All inputs are passed explicitly (no closures over the caller's locals). + */ +export function makeMiniflareWorker( + context: MakeMiniflareWorkerContext, + options: MakeMiniflareWorkerOptions +): any { + const { + cwd, + loadedConfig, + bindings, + sendEmailConfig, + rateLimitsConfig, + versionMetadataConfig, + workerLoadersConfig, + mtlsCertificatesConfig, + dispatchNamespacesConfig, + workflowsConfig, + pipelinesConfig, + hyperdrivesConfig, + imagesConfig, + mediaConfig, + artifactsConfig, + aiSearchNamespacesConfig, + aiSearchInstancesConfig, + secretsStoreConfig, + localSecretWrappedBindingConfig, + queueProducers + } = context + + const baseFlags = loadedConfig.compatibilityFlags ?? [] + const compatFlags = baseFlags.includes('nodejs_compat') + ? baseFlags + : [...baseFlags, 'nodejs_compat'] + const workerBindings: Record = loadedConfig.vars ?? {} + const localWrappedBindings = { + ...(localSecretWrappedBindingConfig?.wrappedBindings ?? {}) + } + + const workerConfig: any = { + name: options.name, + modules: true, + compatibilityDate: loadedConfig.compatibilityDate, + compatibilityFlags: compatFlags, + ...(bindings.kv && { + kvNamespaces: Object.fromEntries( + Object.entries(bindings.kv).map(([bindingName, bindingConfig]) => { + return [bindingName, getLocalKVNamespaceIdentifier(bindingConfig)] + }) + ) + }), + ...(bindings.r2 && { r2Buckets: bindings.r2 }), + ...(bindings.d1 && { + d1Databases: Object.fromEntries( + Object.entries(bindings.d1).map(([bindingName, bindingConfig]) => { + return [bindingName, getLocalD1DatabaseIdentifier(bindingConfig)] + }) + ) + }), + ...(Object.keys(workerBindings).length > 0 && { bindings: workerBindings }), + ...(sendEmailConfig && { email: sendEmailConfig }), + ...(rateLimitsConfig && { ratelimits: rateLimitsConfig }), + ...(versionMetadataConfig && { versionMetadata: versionMetadataConfig }), + ...(workerLoadersConfig && { workerLoaders: workerLoadersConfig }), + ...(mtlsCertificatesConfig && { mtlsCertificates: mtlsCertificatesConfig }), + ...(dispatchNamespacesConfig && { dispatchNamespaces: dispatchNamespacesConfig }), + ...(workflowsConfig && { workflows: workflowsConfig }), + ...(pipelinesConfig && { pipelines: pipelinesConfig }), + ...(hyperdrivesConfig && { hyperdrives: hyperdrivesConfig }), + ...(imagesConfig && { images: imagesConfig }), + ...(mediaConfig && { media: mediaConfig }), + ...(artifactsConfig && { artifacts: artifactsConfig }), + ...(aiSearchNamespacesConfig && { aiSearchNamespaces: aiSearchNamespacesConfig }), + ...(aiSearchInstancesConfig && { aiSearchInstances: aiSearchInstancesConfig }), + ...(secretsStoreConfig && { secretsStoreSecrets: secretsStoreConfig }), + ...(Object.keys(localWrappedBindings).length > 0 && { wrappedBindings: localWrappedBindings }), + ...(queueProducers && { queueProducers }), + ...(options.queueConsumers && { queueConsumers: options.queueConsumers }), + ...(options.triggers && { triggers: options.triggers }) + } + + if (options.scriptPath) { + workerConfig.scriptPath = options.scriptPath + workerConfig.modulesRoot = loadedConfig.baseDir + ? resolve(cwd, loadedConfig.baseDir) + : cwd + workerConfig.modulesRules = [ + ...(loadedConfig.rules?.map(toMiniflareModuleRule) ?? []), + ...DEFAULT_MODULE_RULES + ] + } + + if (options.script) { + workerConfig.script = options.script + } + + if (options.durableObjects && Object.keys(options.durableObjects).length > 0) { + workerConfig.durableObjects = options.durableObjects + } + + if (options.serviceBindings && Object.keys(options.serviceBindings).length > 0) { + workerConfig.serviceBindings = options.serviceBindings + } + + return workerConfig +} diff --git a/packages/devflare/src/dev-server/reload-queue.ts b/packages/devflare/src/dev-server/reload-queue.ts new file mode 100644 index 0000000..469c5bf --- /dev/null +++ b/packages/devflare/src/dev-server/reload-queue.ts @@ -0,0 +1,68 @@ +// ============================================================================= +// Reload Queue โ€” coalesces concurrent reload requests +// ============================================================================= +// Runs at most one reload at a time. Requests made while a reload is in flight +// are coalesced into a single trailing reload. Errors are surfaced through the +// supplied logger instead of being dropped silently. +// ============================================================================= + +import type { ConsolaInstance } from 'consola' + +export interface ReloadQueueOptions { + reload: () => Promise + logger?: ConsolaInstance +} + +export interface ReloadQueue { + /** Request a reload. Returns a promise that resolves when this request's reload finishes. */ + schedule(): Promise + /** Wait until there is no running or pending reload. */ + drain(): Promise +} + +export function createReloadQueue({ reload, logger }: ReloadQueueOptions): ReloadQueue { + let running: Promise | null = null + let pending: Promise | null = null + + async function runOnce(): Promise { + try { + await reload() + } catch (error) { + logger?.error('[devflare dev] reload failed:', error) + } + } + + function schedule(): Promise { + if (!running) { + running = runOnce().finally(() => { + running = null + }) + return running + } + + if (!pending) { + const runningSnapshot = running + pending = runningSnapshot.then(() => { + pending = null + running = runOnce().finally(() => { + running = null + }) + return running + }) + } + + return pending + } + + async function drain(): Promise { + while (running || pending) { + if (pending) { + await pending + } else if (running) { + await running + } + } + } + + return { schedule, drain } +} diff --git a/packages/devflare/src/dev-server/runtime-stdio.ts b/packages/devflare/src/dev-server/runtime-stdio.ts new file mode 100644 index 0000000..ba78c8a --- /dev/null +++ b/packages/devflare/src/dev-server/runtime-stdio.ts @@ -0,0 +1,43 @@ +import { createInterface } from 'node:readline' +import type { Readable } from 'node:stream' + +export interface RuntimeStdioLogger { + log?(message: string): void + info?(message: string): void + error?(message: string): void +} + +function writeStdout(logger: RuntimeStdioLogger | undefined, message: string): void { + if (typeof logger?.log === 'function') { + logger.log(message) + return + } + + if (typeof logger?.info === 'function') { + logger.info(message) + return + } + + console.log(message) +} + +function writeStderr(logger: RuntimeStdioLogger | undefined, message: string): void { + if (typeof logger?.error === 'function') { + logger.error(message) + return + } + + console.error(message) +} + +export function createRuntimeStdioForwarder(logger?: RuntimeStdioLogger) { + return (stdout: Readable, stderr: Readable): void => { + createInterface({ input: stdout }).on('line', (data) => { + writeStdout(logger, data) + }) + + createInterface({ input: stderr }).on('line', (data) => { + writeStderr(logger, data) + }) + } +} \ No newline at end of file diff --git a/packages/devflare/src/dev-server/server-startup-helpers.ts b/packages/devflare/src/dev-server/server-startup-helpers.ts new file mode 100644 index 0000000..b2927d1 --- /dev/null +++ b/packages/devflare/src/dev-server/server-startup-helpers.ts @@ -0,0 +1,274 @@ +// ============================================================================= +// Dev Server โ€” startup-time helpers +// ============================================================================= +// Pure helpers extracted from createDevServer().start(). All inputs are +// explicit so these can be unit-tested without spinning up Miniflare. +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import type { Miniflare as MiniflareType } from 'miniflare' +import { resolve } from 'pathe' +import { resolveConfigPath } from '../config/loader' +import { createBrowserShim, type BrowserShim } from '../browser-shim' +import { createDOBundler, type DOBundler, type DOBundleResult } from '../bundler' +import { writeGeneratedViteConfig } from '../vite' +import { resolveViteMode } from './vite-utils' +import type { DevflareConfig } from '../config/schema' +import { getSingleBrowserBindingName } from '../config/schema' +import type { RouteDiscoveryResult } from '../worker-entry/routes' +import type { WorkerSurfacePaths } from './worker-surface-paths' +import type { checkRemoteBindingRequirements } from '../cli/wrangler-auth' + +type RemoteBindingCheck = Awaited> + +/** + * Emit informational/warning lines about detected worker handlers in + * worker-only (no-Vite) mode. + */ +export function logWorkerHandlerDetection( + logger: ConsolaInstance | undefined, + enableVite: boolean, + hasSurface: boolean, + mainWorkerSurfacePaths: WorkerSurfacePaths, + mainWorkerRoutes: RouteDiscoveryResult | null +): void { + if (enableVite) return + + if (hasSurface) { + const detectedWorkerHandlers = Object.entries(mainWorkerSurfacePaths) + .filter(([, surfacePath]) => !!surfacePath) + .map(([surfaceName, surfacePath]) => `${surfaceName}=${surfacePath}`) + const detectedRouteHandlers = mainWorkerRoutes?.routes.map( + (route) => `route=${route.filePath}` + ) ?? [] + logger?.info( + `Worker handlers detected: ${[...detectedWorkerHandlers, ...detectedRouteHandlers].join(', ')}` + ) + } else { + logger?.warn('No local worker handler entry was found for worker-only mode') + } +} + +/** + * Emit warnings about remote-only bindings (AI, Vectorize) and any missing + * prerequisites (accountId, wrangler login). + */ +export function logRemoteBindingRequirements( + logger: ConsolaInstance | undefined, + remoteCheck: RemoteBindingCheck +): void { + if (!remoteCheck.hasRemoteBindings) return + + logger?.info('') + logger?.warn('โš ๏ธ Remote-only bindings detected:') + for (const binding of remoteCheck.remoteBindings) { + logger?.warn(` โ€ข ${binding}`) + } + logger?.info('') + + if (remoteCheck.missingAccountId) { + logger?.warn('โš ๏ธ WARN: accountId is not set in devflare.config.ts') + logger?.warn(' Remote bindings (AI, Vectorize) require accountId to charge the correct account.') + logger?.warn(' Add: accountId: \'your-cloudflare-account-id\'') + logger?.info('') + } + + if (remoteCheck.notLoggedIn) { + logger?.warn('โš ๏ธ WARN: Not logged in to Wrangler') + logger?.warn(' Remote bindings require authentication.') + logger?.warn(' Run: bunx wrangler login') + logger?.info('') + } + + if (!remoteCheck.missingAccountId && !remoteCheck.notLoggedIn) { + logger?.success('โœ“ Remote binding requirements met') + logger?.info('') + } +} + +export function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Resolve the path to watch for config changes. Prefers an explicit + * `configPath` if it's directly accessible on disk, otherwise falls back to + * the auto-discovered config path under `cwd`. Returns `null` when neither + * is available. + */ +export async function resolveWorkerConfigWatchPath( + cwd: string, + configPath: string | undefined +): Promise { + if (configPath) { + const explicitPath = resolve(cwd, configPath) + const fs = await import('node:fs/promises') + try { + await fs.access(explicitPath) + return explicitPath + } catch { + // Fall back to config discovery below when the explicit path is not directly watchable. + } + } + + return await resolveConfigPath(cwd) ?? null +} + +/** + * Pretty-print the resolved Miniflare config (truncating long inline scripts) + * before `Miniflare` is constructed. Only emits when verbose/debug logging is + * requested by the caller. + */ +export function logMiniflareConfigDiagnostics( + logger: ConsolaInstance | undefined, + mfConfig: any +): void { + logger?.info('=== MINIFLARE CONFIG DEBUG ===') + logger?.info('Full config:', JSON.stringify(mfConfig, (key, value) => { + if (key === 'script' && typeof value === 'string' && value.length > 200) { + return value.substring(0, 200) + '...[truncated]' + } + return value + }, 2)) + + if (mfConfig.workers) { + logger?.info('Workers order:') + for (const w of mfConfig.workers) { + logger?.info(` โ†’ ${w.name}:`) + logger?.info(` script: ${w.script ? 'inline' : w.scriptPath}`) + logger?.info(` browserRendering: ${JSON.stringify(w.browserRendering)}`) + logger?.info(` durableObjects: ${JSON.stringify(w.durableObjects)}`) + } + } +} + +/** + * After `Miniflare` is `ready`, query each declared worker's bindings and + * log them. Best-effort: any per-worker failure is logged at debug and does + * not abort the rest of the diagnostics. + */ +export async function logMiniflareBindingDiagnostics( + logger: ConsolaInstance | undefined, + miniflare: MiniflareType, + mfConfig: any +): Promise { + try { + const gatewayBindings = await miniflare.getBindings('gateway') + logger?.info('Gateway worker bindings:', Object.keys(gatewayBindings)) + + if (mfConfig.workers) { + for (const w of mfConfig.workers) { + if (w.name !== 'gateway') { + try { + const doBindings = await miniflare.getBindings(w.name) + logger?.info(`${w.name} worker bindings:`, Object.keys(doBindings)) + if ('BROWSER' in doBindings) { + logger?.success(`${w.name} has BROWSER binding!`) + } else { + logger?.warn(`${w.name} is MISSING BROWSER binding`) + } + } catch (error) { + logger?.debug(`Skipping binding diagnostics for ${w.name}: ${formatErrorMessage(error)}`) + } + } + } + } + } catch (error) { + logger?.debug(`Skipping Miniflare binding diagnostics: ${formatErrorMessage(error)}`) + } +} + +/** + * If the config declares a single browser-rendering binding, construct and + * start a `BrowserShim` listening on `browserShimPort`. Returns `null` when + * no browser binding is configured (no shim needed). + */ +export async function maybeStartBrowserShim( + config: DevflareConfig, + options: { browserShimPort: number; logger?: ConsolaInstance; verbose: boolean } +): Promise { + const browserBinding = getSingleBrowserBindingName(config.bindings?.browser) + if (!browserBinding) return null + + options.logger?.info(`Starting Browser Rendering shim (binding: ${browserBinding})...`) + const shim = createBrowserShim({ + port: options.browserShimPort, + host: '127.0.0.1', + logger: options.logger, + verbose: options.verbose + }) + await shim.start() + return shim +} + +/** + * If the config declares a `files.durableObjects` glob, construct a + * `DOBundler`, run an initial build, and start watching. Returns + * `{ bundler, result }` (`{ null, null }` when no DO pattern is configured). + * The `onRebuild` callback fires on each subsequent rebuild โ€” typically used + * to schedule a Miniflare reload. + */ +export async function maybeStartDOBundler( + config: DevflareConfig, + options: { + cwd: string + logger?: ConsolaInstance + onRebuild: (result: DOBundleResult) => Promise + } +): Promise<{ bundler: DOBundler | null; result: DOBundleResult | null }> { + const doPattern = config.files?.durableObjects + if (typeof doPattern !== 'string' || !doPattern) { + return { bundler: null, result: null } + } + + const outDir = resolve(options.cwd, '.devflare/do-bundles') + const bundler = createDOBundler({ + cwd: options.cwd, + pattern: doPattern, + outDir, + rolldownOptions: config.rolldown?.options, + sourcemap: config.rolldown?.sourcemap, + minify: config.rolldown?.minify, + logger: options.logger, + onRebuild: options.onRebuild + }) + + const result = await bundler.build() + await bundler.watch() + return { bundler, result } +} + +/** + * Resolve whether Vite should run for this package, and if so, write the + * generated Vite config under `.devflare/`. Returns: + * - `{ enableVite: false, generatedViteConfigPath: null }` when Vite is + * not requested or no Vite config exists for this package + * - `{ enableVite: true, generatedViteConfigPath }` after writing the + * generated config + */ +export async function resolveViteIntegration(options: { + cwd: string + configPath: string | undefined + miniflarePort: number + enableViteRequested: boolean + logger?: ConsolaInstance +}): Promise<{ enableVite: boolean; generatedViteConfigPath: string | null }> { + if (!options.enableViteRequested) { + return { enableVite: false, generatedViteConfigPath: null } + } + + const viteMode = await resolveViteMode(options.cwd, { requested: true }) + if (!viteMode.enableVite) { + options.logger?.info('Vite disabled: no vite config found for this package') + return { enableVite: false, generatedViteConfigPath: null } + } + + const generatedViteConfigPath = await writeGeneratedViteConfig({ + cwd: options.cwd, + configPath: options.configPath, + localConfigPath: viteMode.viteConfigPath, + bridgePort: options.miniflarePort + }) + options.logger?.debug(`Generated Vite config โ†’ ${generatedViteConfigPath}`) + return { enableVite: true, generatedViteConfigPath } +} diff --git a/packages/devflare/src/dev-server/server.ts b/packages/devflare/src/dev-server/server.ts new file mode 100644 index 0000000..4ceb459 --- /dev/null +++ b/packages/devflare/src/dev-server/server.ts @@ -0,0 +1,442 @@ +// ============================================================================= +// Dev Server โ€” Miniflare-first Development Experience +// ============================================================================= +// Provides Miniflare, DO hot reload, and optional Vite integration when enabled +// ============================================================================= + +import type { ConsolaInstance } from 'consola' +import type { Miniflare as MiniflareType } from 'miniflare' +import { dirname, resolve } from 'pathe' +import { loadConfig } from '../config/loader' +import { applyLocalDevVarsToConfig } from '../config/local-dev-vars' +import { + EnvVarResolutionError, + getDevflareDotenvPaths, + resolveConfigEnvVars +} from '../config/env-vars' +import { bundleWorkerEntry, type DOBundleResult } from '../bundler' +import { checkRemoteBindingRequirements } from '../cli/wrangler-auth' +import { setLocalSendEmailBindings } from '../utils/send-email' +import { prepareComposedWorkerEntrypoint } from '../worker-entry/composed-worker' +import { discoverRoutes } from '../worker-entry/routes' +import { runD1Migrations } from './d1-migrations' +import { createMiniflareLog } from './miniflare-log' +import { buildMiniflareDevConfig } from './miniflare-dev-config' +import { createRuntimeStdioForwarder } from './runtime-stdio' +import { startViteProcess } from './vite-process' +import { createReloadQueue } from './reload-queue' +import { + collectWorkerWatchRoots, + hasWorkerSurfacePaths, + resolveMainWorkerSurfacePaths +} from './worker-surface-paths' +import { applyWatcherTargetDiff, startWorkerSourceWatcher as createWorkerSourceWatcher } from './worker-source-watcher' +import { logMiniflareBindingDiagnostics, logMiniflareConfigDiagnostics, logRemoteBindingRequirements, logWorkerHandlerDetection, maybeStartBrowserShim, maybeStartDOBundler, resolveViteIntegration, resolveWorkerConfigWatchPath } from './server-startup-helpers' +import { createDevServerState, disposeDevServerState, type DevServerState } from './dev-server-state' +import { bundleWorkflowEntrypointScript } from '../workflows/local-workflow-entrypoints' +import { resolveServiceBindings } from '../test/resolve-service-bindings' + +// ----------------------------------------------------------------------------- + +export interface DevServerOptions { + /** Project root directory */ + cwd: string + /** Config file path (optional) */ + configPath?: string + /** Vite dev server port (default: 5173) */ + vitePort?: number + /** Miniflare port for gateway (default: 8787) */ + miniflarePort?: number + /** Whether to start Vite for this package */ + enableVite?: boolean + /** Persist storage data */ + persist?: boolean + /** Logger instance */ + logger?: ConsolaInstance + /** Enable verbose logging */ + verbose?: boolean + /** Enable debug mode (extra logging in gateway worker) */ + debug?: boolean +} + +export interface DevServer { + /** Start the dev server */ + start(): Promise + /** Stop the dev server */ + stop(): Promise + /** Get Miniflare instance for testing */ + getMiniflare(): MiniflareType | null +} + +// ----------------------------------------------------------------------------- +// Dev Server Implementation +// ----------------------------------------------------------------------------- + +export function createDevServer(options: DevServerOptions): DevServer { + const { + cwd, + configPath, + vitePort = 5173, + miniflarePort = 8787, + enableVite: enableViteRequested = true, + persist = true, // Default to true for dev - migrations need persistence + logger, + verbose = false, + debug = process.env.DEVFLARE_DEBUG === 'true' + } = options + + const state: DevServerState = createDevServerState({ enableVite: enableViteRequested }) + + const reloadQueue = createReloadQueue({ + reload: async () => { + if (!state.miniflare) return + + const { Log, LogLevel } = await import('miniflare') + const mfConfig = buildMiniflareConfig(state.currentDoResult) + // Always enable debug logging to see worker load errors + const log = createMiniflareLog(Log, LogLevel, 'DEBUG', logger) + if (log) { + mfConfig.log = log as typeof mfConfig.log + } + mfConfig.handleRuntimeStdio = createRuntimeStdioForwarder(logger) + + logger?.info('Reloading Miniflare...') + await state.miniflare.setOptions(mfConfig) + logger?.success('Miniflare reloaded') + }, + logger + }) + + async function bundleMainWorker(): Promise { + if (!state.mainWorkerScriptPath || !state.config) { + state.bundledMainWorkerScriptPath = null + return + } + + state.bundledMainWorkerScriptPath = await bundleWorkerEntry({ + cwd, + inputFile: state.mainWorkerScriptPath, + outFile: resolve(cwd, '.devflare', 'worker-entrypoints', 'main.js'), + rolldownOptions: state.config.rolldown?.options, + sourcemap: state.config.rolldown?.sourcemap, + minify: state.config.rolldown?.minify, + logger + }) + logger?.debug(`Bundled main worker โ†’ ${state.bundledMainWorkerScriptPath}`) + } + + function buildMiniflareConfig(doResult: DOBundleResult | null) { + if (!state.config) throw new Error('Config not loaded') + + return buildMiniflareDevConfig({ + config: state.config, + cwd, + miniflarePort, + persist, + enableVite: state.enableVite, + debug, + mainWorkerSurfacePaths: state.mainWorkerSurfacePaths, + mainWorkerRoutes: state.mainWorkerRoutes, + mainWorkerScriptPath: state.mainWorkerScriptPath, + bundledMainWorkerScriptPath: state.bundledMainWorkerScriptPath, + workflowEntrypointScript: state.workflowEntrypointScript, + browserShimPort: state.browserShimPort, + doResult, + serviceBindingResolution: state.serviceBindingResolution, + logger + }) + } + + async function bundleWorkflowEntrypoints(): Promise { + if (!state.config) { + state.workflowEntrypointScript = '' + return + } + + state.workflowEntrypointScript = await bundleWorkflowEntrypointScript(state.config, cwd, { + logger + }) + } + + /** + * Start Miniflare with current config + */ + async function startMiniflare(doResult: DOBundleResult | null): Promise { + const { Miniflare, Log, LogLevel } = await import('miniflare') + + const mfConfig = buildMiniflareConfig(doResult) + const log = createMiniflareLog(Log, LogLevel, 'DEBUG', logger) + if (log) { + mfConfig.log = log as typeof mfConfig.log + } + mfConfig.handleRuntimeStdio = createRuntimeStdioForwarder(logger) + const shouldLogMiniflareDiagnostics = verbose || debug + + if (shouldLogMiniflareDiagnostics) { + logMiniflareConfigDiagnostics(logger, mfConfig) + } + + state.miniflare = new Miniflare(mfConfig) + await state.miniflare.ready + + logger?.success(`Miniflare ready on http://localhost:${miniflarePort}`) + + if (shouldLogMiniflareDiagnostics) { + await logMiniflareBindingDiagnostics(logger, state.miniflare, mfConfig) + } + } + + /** + * Reload Miniflare with updated DO bundles + */ + async function reloadMiniflare(doResult: DOBundleResult | null): Promise { + state.currentDoResult = doResult + await reloadQueue.schedule() + } + + + + async function refreshWorkerOnlySurfaceState(): Promise { + if (!state.config) { + return + } + + state.mainWorkerSurfacePaths = await resolveMainWorkerSurfacePaths(cwd, state.config) + state.mainWorkerRoutes = await discoverRoutes(cwd, state.config) + const composedMainEntry = await prepareComposedWorkerEntrypoint(cwd, state.config, undefined, { + devInternalEmail: true + }) + state.mainWorkerScriptPath = composedMainEntry ? composedMainEntry : null + + if (state.mainWorkerScriptPath) { + await bundleMainWorker() + } else { + state.bundledMainWorkerScriptPath = null + } + + await syncWorkerWatchTargets() + } + + function getWorkerWatchTargets(): string[] { + if (state.enableVite || !state.config) { + return [] + } + + const targets = collectWorkerWatchRoots(cwd, state.config, state.mainWorkerSurfacePaths) + if (state.resolvedWorkerConfigPath) { + targets.push(state.resolvedWorkerConfigPath) + } + + return [...new Set(targets)] + } + + async function syncWorkerWatchTargets(): Promise { + if (!state.workerSourceWatcher) { + return + } + state.workerWatchTargets = await applyWatcherTargetDiff( + state.workerSourceWatcher, + state.workerWatchTargets, + getWorkerWatchTargets() + ) + } + + async function reloadWorkerOnlyConfig(): Promise { + await loadRuntimeConfigWhenEnvReady() + if (!state.config) { + return + } + setLocalSendEmailBindings(state.config.bindings?.sendEmail ?? {}) + await bundleWorkflowEntrypoints() + await refreshWorkerOnlySurfaceState() + await reloadMiniflare(state.currentDoResult) + } + + async function loadRuntimeConfig(): Promise { + state.resolvedWorkerConfigPath = await resolveWorkerConfigWatchPath(cwd, configPath) + const loadedConfig = await loadConfig({ cwd, configFile: configPath }) + const envResolvedConfig = await resolveConfigEnvVars(loadedConfig, { + cwd, + configPath: state.resolvedWorkerConfigPath ?? configPath, + mode: 'dev' + }) + state.config = await applyLocalDevVarsToConfig(envResolvedConfig, { + cwd, + configPath: state.resolvedWorkerConfigPath ?? undefined + }) + state.serviceBindingResolution = state.config.bindings?.services + ? await resolveServiceBindings( + state.config, + state.resolvedWorkerConfigPath ? dirname(state.resolvedWorkerConfigPath) : cwd + ) + : null + } + + async function waitForDotenvChange(): Promise { + const configWatchPath = state.resolvedWorkerConfigPath + ?? await resolveWorkerConfigWatchPath(cwd, configPath) + const startDir = configWatchPath ? dirname(configWatchPath) : cwd + const watchPaths = getDevflareDotenvPaths(startDir) + const { watch } = await import('chokidar') + + await new Promise((resolveWait, rejectWait) => { + const watcher = watch(watchPaths, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 25 + } + }) + const finish = () => { + watcher.close().then(resolveWait, rejectWait) + } + + watcher.on('add', finish) + watcher.on('change', finish) + watcher.on('unlink', finish) + watcher.on('error', rejectWait) + }) + } + + async function loadRuntimeConfigWhenEnvReady(): Promise { + while (true) { + try { + await loadRuntimeConfig() + return + } catch (error) { + if (!(error instanceof EnvVarResolutionError)) { + throw error + } + + logger?.warn(error.message) + logger?.info('Devflare dev is waiting for .env or .env.dev to change before starting.') + await waitForDotenvChange() + } + } + } + + async function startWorkerSourceWatcher(): Promise { + if (state.enableVite || !state.config) { + return + } + + const watchTargets = getWorkerWatchTargets() + if (watchTargets.length === 0) { + return + } + + state.workerWatchTargets = watchTargets + state.workerSourceWatcher = await createWorkerSourceWatcher({ + watchTargets, + resolvedWorkerConfigPath: state.resolvedWorkerConfigPath, + logger, + onConfigChange: reloadWorkerOnlyConfig, + onWorkerChange: async () => { + await refreshWorkerOnlySurfaceState() + await reloadMiniflare(state.currentDoResult) + } + }) + } + + /** + * Start the complete dev server + */ + async function start(): Promise { + logger?.info('Starting unified dev server...') + + // Load config + await loadRuntimeConfigWhenEnvReady() + if (!state.config) { + throw new Error('Config not loaded') + } + setLocalSendEmailBindings(state.config.bindings?.sendEmail ?? {}) + logger?.debug('Loaded config:', state.config.name) + await bundleWorkflowEntrypoints() + const viteIntegration = await resolveViteIntegration({ + cwd, + configPath, + miniflarePort, + enableViteRequested: state.enableVite, + logger + }) + state.enableVite = viteIntegration.enableVite + state.generatedViteConfigPath = viteIntegration.generatedViteConfigPath + await refreshWorkerOnlySurfaceState() + + if ( + !state.enableVite + && (hasWorkerSurfacePaths(state.mainWorkerSurfacePaths) || Boolean(state.mainWorkerRoutes?.routes.length)) + ) { + logWorkerHandlerDetection( + logger, + state.enableVite, + true, + state.mainWorkerSurfacePaths, + state.mainWorkerRoutes + ) + } else if (!state.enableVite) { + logWorkerHandlerDetection(logger, state.enableVite, false, state.mainWorkerSurfacePaths, state.mainWorkerRoutes) + } + + // Check for remote bindings and warn if requirements not met + const remoteCheck = await checkRemoteBindingRequirements(state.config) + logRemoteBindingRequirements(logger, remoteCheck) + + // Start browser shim if browser rendering is configured + state.browserShim = await maybeStartBrowserShim(state.config, { browserShimPort: state.browserShimPort, logger, verbose }) + + // Bundle DOs if pattern is set + const doInit = await maybeStartDOBundler(state.config, { + cwd, + logger, + onRebuild: async (result) => { + // Hot reload Miniflare when DOs change + await reloadMiniflare(result) + } + }) + state.doBundler = doInit.bundler + const doResult: DOBundleResult | null = doInit.result + state.currentDoResult = doResult + + // Start Miniflare + await startMiniflare(doResult) + await startWorkerSourceWatcher() + + if (state.enableVite) { + state.viteProcess = await startViteProcess({ + cwd, + configPath, + vitePort, + miniflarePort, + generatedViteConfigPath: state.generatedViteConfigPath, + logger + }) + } else { + logger?.info('Vite startup skipped (no effective Vite config found for this package)') + } + + // Run D1 migrations after the dev runtime is started (give Miniflare more time to stabilize) + await new Promise((r) => setTimeout(r, 1000)) + await runD1Migrations({ cwd, config: state.config, miniflarePort, logger }) + } + + /** + * Stop the dev server + */ + async function stop(): Promise { + await disposeDevServerState(state) + } + + /** + * Get Miniflare instance + */ + function getMiniflare(): MiniflareType | null { + return state.miniflare + } + + return { + start, + stop, + getMiniflare + } +} diff --git a/packages/devflare/src/dev-server/vite-process.ts b/packages/devflare/src/dev-server/vite-process.ts new file mode 100644 index 0000000..23b73f5 --- /dev/null +++ b/packages/devflare/src/dev-server/vite-process.ts @@ -0,0 +1,61 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import type { ConsolaInstance } from 'consola' +import { waitForViteReady } from './vite-utils' + +export interface StartViteProcessOptions { + cwd: string + configPath?: string + vitePort: number + miniflarePort: number + generatedViteConfigPath: string | null + logger?: ConsolaInstance +} + +/** + * Start the Vite dev server process. + */ +export async function startViteProcess(options: StartViteProcessOptions): Promise { + const { + cwd, + configPath, + vitePort, + miniflarePort, + generatedViteConfigPath, + logger + } = options + + const args = ['vite', 'dev', '--port', String(vitePort)] + if (generatedViteConfigPath) { + args.push('--config', generatedViteConfigPath) + } + + const viteProcess = spawn('bunx', args, { + cwd, + stdio: ['inherit', 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + DEVFLARE_DEV: 'true', + DEVFLARE_BRIDGE_PORT: String(miniflarePort), + ...(configPath ? { DEVFLARE_CONFIG_PATH: configPath } : {}), + FORCE_COLOR: '1' + } + }) + + const readyUrl = await waitForViteReady(viteProcess, { + onStdout(chunk) { + process.stdout.write(chunk) + }, + onStderr(chunk) { + process.stderr.write(chunk) + } + }) + + if (readyUrl) { + logger?.success(`Vite dev server started on ${readyUrl}`) + return viteProcess + } + + logger?.warn('Vite process started, but the final local URL could not be confirmed yet') + return viteProcess +} diff --git a/packages/devflare/src/dev-server/vite-utils.ts b/packages/devflare/src/dev-server/vite-utils.ts new file mode 100644 index 0000000..2d1c368 --- /dev/null +++ b/packages/devflare/src/dev-server/vite-utils.ts @@ -0,0 +1,309 @@ +import { resolve } from 'pathe' +import { spawn } from 'node:child_process' + +export const VITE_CONFIG_FILES = [ + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mts', + 'vite.config.mjs', + 'vite.config.cts', + 'vite.config.cjs' +] as const + +export interface ViteProjectFileSystem { + access(path: string): Promise + readFile(path: string, encoding: BufferEncoding): Promise +} + +export interface ViteProjectDetection { + viteConfigPath: string | null + hasLocalViteDependency: boolean + hasLocalCloudflareVitePluginDependency: boolean + shouldStartVite: boolean + wantsViteIntegration: boolean +} + +export interface SpawnedLikeProcess { + pid?: number + stdout: NodeJS.ReadableStream | null + stderr: NodeJS.ReadableStream | null + readonly killed: boolean + kill(signal?: NodeJS.Signals): boolean + on(event: 'exit', handler: (code: number | null, signal: NodeJS.Signals | null) => void): SpawnedLikeProcess + on(event: 'error', handler: (error: Error) => void): SpawnedLikeProcess +} + +export interface WaitForViteReadyOptions { + timeoutMs?: number + onStdout?: (chunk: string | Buffer) => void + onStderr?: (chunk: string | Buffer) => void +} + +export interface StopProcessTreeOptions { + platform?: NodeJS.Platform + timeoutMs?: number + runCommand?: (command: string, args: string[]) => Promise +} + +const ANSI_REGEX = /\x1b\[[0-9;]*m/g +const LOCAL_VITE_URL_REGEX = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\/?/i + +async function getNodeFs(): Promise { + return await import('node:fs/promises') as unknown as ViteProjectFileSystem +} + +function safeParsePackageJson(content: string): Record { + try { + return JSON.parse(content) as Record + } catch { + return {} + } +} + +function readDependencyFlag(pkg: Record, name: string): boolean { + const dependencies = pkg.dependencies as Record | undefined + const devDependencies = pkg.devDependencies as Record | undefined + + return Boolean(dependencies?.[name] ?? devDependencies?.[name]) +} + +export async function detectViteProject( + cwd: string, + fs?: ViteProjectFileSystem +): Promise { + const fileSystem = fs ?? await getNodeFs() + let viteConfigPath: string | null = null + + for (const configName of VITE_CONFIG_FILES) { + const absolutePath = resolve(cwd, configName) + try { + await fileSystem.access(absolutePath) + viteConfigPath = absolutePath + break + } catch { + continue + } + } + + let pkg: Record = {} + try { + const packageJson = await fileSystem.readFile(resolve(cwd, 'package.json'), 'utf-8') + pkg = safeParsePackageJson(packageJson) + } catch { + pkg = {} + } + + const hasLocalViteDependency = readDependencyFlag(pkg, 'vite') + const hasLocalCloudflareVitePluginDependency = readDependencyFlag(pkg, '@cloudflare/vite-plugin') + const wantsViteIntegration = Boolean( + viteConfigPath || hasLocalViteDependency || hasLocalCloudflareVitePluginDependency + ) + + return { + viteConfigPath, + hasLocalViteDependency, + hasLocalCloudflareVitePluginDependency, + shouldStartVite: Boolean(viteConfigPath), + wantsViteIntegration + } +} + +export interface ResolvedViteMode { + /** Whether the dev server should actually start Vite for this cwd. */ + enableVite: boolean + /** Resolved Vite config path, or null if none was found. */ + viteConfigPath: string | null + /** The raw detection outcome for callers that want details. */ + detection: ViteProjectDetection +} + +/** + * Resolve the effective Vite mode for a project. Combines the caller's + * `requested` preference with the filesystem detection so that callers who ask + * for Vite but lack a local config are downgraded to worker-only mode rather + * than silently having the detection result ignored. + */ +export async function resolveViteMode( + cwd: string, + options: { requested?: boolean; fs?: ViteProjectFileSystem } = {} +): Promise { + const detection = await detectViteProject(cwd, options.fs) + const requested = options.requested ?? true + return { + enableVite: requested && detection.shouldStartVite, + viteConfigPath: detection.viteConfigPath, + detection + } +} + +export function stripAnsi(value: string): string { + return value.replace(ANSI_REGEX, '') +} + +export function extractViteReadyUrl(output: string): string | null { + const cleaned = stripAnsi(output) + const lines = cleaned + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + for (const line of lines) { + if (!line.toLowerCase().includes('local:')) { + continue + } + + const match = line.match(LOCAL_VITE_URL_REGEX) + if (match) { + return match[0] + } + } + + const fallbackMatch = cleaned.match(LOCAL_VITE_URL_REGEX) + return fallbackMatch?.[0] ?? null +} + +export async function waitForViteReady( + process: SpawnedLikeProcess, + options: WaitForViteReadyOptions = {} +): Promise { + const { + timeoutMs = 15000, + onStdout, + onStderr + } = options + + let combinedOutput = '' + + return await new Promise((resolvePromise, rejectPromise) => { + let settled = false + let timeout: ReturnType + + const settle = (resolver: () => void) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolver() + } + + const inspectChunk = (chunk: string | Buffer) => { + combinedOutput += typeof chunk === 'string' ? chunk : chunk.toString('utf-8') + const readyUrl = extractViteReadyUrl(combinedOutput) + if (readyUrl) { + settle(() => resolvePromise(readyUrl)) + } + } + + process.stdout?.on('data', (chunk: string | Buffer) => { + onStdout?.(chunk) + inspectChunk(chunk) + }) + + process.stderr?.on('data', (chunk: string | Buffer) => { + onStderr?.(chunk) + inspectChunk(chunk) + }) + + process.on('error', (error) => { + settle(() => rejectPromise(error)) + }) + + process.on('exit', (code, signal) => { + settle(() => { + const reason = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}` + rejectPromise(new Error(`Vite exited before reporting a ready URL (${reason})`)) + }) + }) + + timeout = setTimeout(() => { + settle(() => resolvePromise(null)) + }, timeoutMs) + }) +} + +async function defaultRunCommand(command: string, args: string[]): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + stdio: 'ignore', + windowsHide: true + }) + + child.on('error', rejectPromise) + child.on('exit', () => resolvePromise()) + }) +} + +function waitForProcessExit( + process: Pick, + timeoutMs: number +): Promise { + if (process.killed) { + return Promise.resolve(true) + } + + return new Promise((resolvePromise) => { + let settled = false + let timeout: ReturnType + + const settle = (value: boolean) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + resolvePromise(value) + } + + process.on('exit', () => settle(true)) + + timeout = setTimeout(() => { + settle(false) + }, timeoutMs) + }) +} + +export async function stopSpawnedProcessTree( + process: Pick, + options: StopProcessTreeOptions = {} +): Promise { + const { + platform = globalThis.process?.platform ?? 'linux', + timeoutMs = 3000, + runCommand = defaultRunCommand + } = options + + if (platform === 'win32' && process.pid) { + try { + await runCommand('taskkill', ['/pid', String(process.pid), '/t', '/f']) + } catch { + try { + process.kill('SIGTERM') + } catch { + return + } + } + + await waitForProcessExit(process, timeoutMs) + return + } + + try { + process.kill('SIGTERM') + } catch { + return + } + + const exited = await waitForProcessExit(process, timeoutMs) + if (exited) { + return + } + + try { + process.kill('SIGKILL') + } catch { + return + } + + await waitForProcessExit(process, timeoutMs) +} diff --git a/packages/devflare/src/dev-server/worker-source-watcher.ts b/packages/devflare/src/dev-server/worker-source-watcher.ts new file mode 100644 index 0000000..750f732 --- /dev/null +++ b/packages/devflare/src/dev-server/worker-source-watcher.ts @@ -0,0 +1,148 @@ +import type { FSWatcher } from 'chokidar' +import type { ConsolaInstance } from 'consola' + +export interface WorkerSourceWatcherOptions { + watchTargets: string[] + resolvedWorkerConfigPath: string | null + logger?: ConsolaInstance + onConfigChange: () => Promise + onWorkerChange: () => Promise +} + +export async function startWorkerSourceWatcher( + options: WorkerSourceWatcherOptions +): Promise { + const { watchTargets, resolvedWorkerConfigPath, logger, onConfigChange, onWorkerChange } = options + + if (watchTargets.length === 0) { + return null + } + + const chokidar = await import('chokidar') + const isWindows = process.platform === 'win32' + const ignoredSegments = ['/node_modules/', '/.git/', '/.devflare/', '/dist/'] + + const normalizePath = (filePath: string) => filePath.replace(/\\/g, '/') + const isIgnoredPath = (filePath: string) => { + const normalizedPath = normalizePath(filePath) + return ignoredSegments.some((segment) => normalizedPath.includes(segment)) + } + + let reloadTimeout: ReturnType | null = null + let reloadInProgress = false + let pendingReloadPath: string | null = null + + const flushPendingReload = async () => { + if (!pendingReloadPath) { + return + } + + const nextPath = pendingReloadPath + pendingReloadPath = null + await triggerReload(nextPath) + } + + const triggerReload = async (filePath: string) => { + if (reloadInProgress) { + pendingReloadPath = filePath + return + } + + reloadInProgress = true + + try { + const normalizedConfigPath = resolvedWorkerConfigPath ? normalizePath(resolvedWorkerConfigPath) : null + if (normalizedConfigPath && normalizePath(filePath) === normalizedConfigPath) { + logger?.info(`Devflare config changed: ${filePath}`) + await onConfigChange() + return + } + + logger?.info(`Worker source changed: ${filePath}`) + await onWorkerChange() + } catch (error) { + logger?.error('Worker source reload failed:', error) + } finally { + reloadInProgress = false + await flushPendingReload() + } + } + + const scheduleReload = (filePath: string) => { + if (reloadTimeout) { + clearTimeout(reloadTimeout) + } + + reloadTimeout = setTimeout(() => { + reloadTimeout = null + void triggerReload(filePath) + }, 150) + } + + const watcher = chokidar.watch(watchTargets, { + ignoreInitial: true, + usePolling: isWindows, + interval: isWindows ? 300 : undefined, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50 + }, + ignored: (filePath) => isIgnoredPath(filePath) + }) + + const onFileEvent = (filePath: string) => { + if (isIgnoredPath(filePath)) { + return + } + + scheduleReload(filePath) + } + + watcher.on('change', onFileEvent) + watcher.on('add', onFileEvent) + watcher.on('unlink', onFileEvent) + watcher.on('error', (error) => { + logger?.error('Worker source watcher error:', error) + }) + + await new Promise((resolvePromise, rejectPromise) => { + const handleReady = () => { + watcher.off('error', handleInitialError) + logger?.info(`Worker source watcher ready (${watchTargets.length} target(s))`) + resolvePromise() + } + + const handleInitialError = (error: unknown) => { + watcher.off('ready', handleReady) + rejectPromise(error instanceof Error ? error : new Error(String(error))) + } + + watcher.once('ready', handleReady) + watcher.once('error', handleInitialError) + }) + + return watcher +} + +/** + * Compute the diff between current and next watch-target lists and apply it + * to the given watcher. Returns the deduped next-targets array which the + * caller should store as the new "current" state. + */ +export async function applyWatcherTargetDiff( + watcher: FSWatcher, + currentTargets: string[], + nextTargets: string[] +): Promise { + const nextSet = new Set(nextTargets) + const targetsToRemove = currentTargets.filter((t) => !nextSet.has(t)) + const targetsToAdd = nextTargets.filter((t) => !currentTargets.includes(t)) + + if (targetsToRemove.length > 0) { + await watcher.unwatch(targetsToRemove) + } + if (targetsToAdd.length > 0) { + watcher.add(targetsToAdd) + } + return nextTargets +} diff --git a/packages/devflare/src/dev-server/worker-surface-paths.ts b/packages/devflare/src/dev-server/worker-surface-paths.ts new file mode 100644 index 0000000..a646a90 --- /dev/null +++ b/packages/devflare/src/dev-server/worker-surface-paths.ts @@ -0,0 +1,70 @@ +import { dirname, resolve } from 'pathe' +import type { DevflareConfig } from '../config' +import { getRouteDirectoryCandidate } from '../worker-entry/routes' +import { + DEFAULT_EMAIL_ENTRY_FILES, + DEFAULT_FETCH_ENTRY_FILES, + DEFAULT_QUEUE_ENTRY_FILES, + DEFAULT_SCHEDULED_ENTRY_FILES, + hasWorkerSurfacePaths, + resolveWorkerSurfacePaths, + type WorkerSurfacePaths +} from '../worker-entry/surface-paths' + +const DEFAULT_TRANSPORT_ENTRY_FILES = [ + 'src/transport.ts', + 'src/transport.js', + 'src/transport.mts', + 'src/transport.mjs' +] as const + +export { hasWorkerSurfacePaths, type WorkerSurfacePaths } + +export const resolveMainWorkerSurfacePaths = resolveWorkerSurfacePaths + +function addWorkerWatchRoots( + roots: Set, + cwd: string, + configuredPath: string | false | null | undefined, + defaultEntries: readonly string[] +): void { + if (configuredPath === false || configuredPath === null) { + return + } + + if (typeof configuredPath === 'string' && configuredPath) { + roots.add(dirname(resolve(cwd, configuredPath))) + return + } + + for (const defaultEntry of defaultEntries) { + roots.add(dirname(resolve(cwd, defaultEntry))) + } +} + +export function collectWorkerWatchRoots( + cwd: string, + config: DevflareConfig, + mainWorkerSurfacePaths: WorkerSurfacePaths +): string[] { + const roots = new Set() + + for (const surfacePath of Object.values(mainWorkerSurfacePaths)) { + if (surfacePath) { + roots.add(dirname(surfacePath)) + } + } + + addWorkerWatchRoots(roots, cwd, config.files?.fetch, DEFAULT_FETCH_ENTRY_FILES) + addWorkerWatchRoots(roots, cwd, config.files?.queue, DEFAULT_QUEUE_ENTRY_FILES) + addWorkerWatchRoots(roots, cwd, config.files?.scheduled, DEFAULT_SCHEDULED_ENTRY_FILES) + addWorkerWatchRoots(roots, cwd, config.files?.email, DEFAULT_EMAIL_ENTRY_FILES) + addWorkerWatchRoots(roots, cwd, config.files?.transport, DEFAULT_TRANSPORT_ENTRY_FILES) + + const routeDirectory = getRouteDirectoryCandidate(cwd, config) + if (routeDirectory) { + roots.add(routeDirectory.absoluteDir) + } + + return [...roots] +} diff --git a/packages/devflare/src/env.ts b/packages/devflare/src/env.ts new file mode 100644 index 0000000..27a0d11 --- /dev/null +++ b/packages/devflare/src/env.ts @@ -0,0 +1,216 @@ +// ============================================================================= +// Unified Environment Proxy +// ============================================================================= +// Smart proxy that tries request-scoped context first, then a test context +// installed by createTestContext, then finally the internal bridge proxy. +// This is the public Cloudflare-world portal โ€” a single +// `import { env } from 'devflare'` works everywhere: +// - Inside request handlers: uses request-scoped context +// - In bun:test / Bun scripts: uses createTestContext state when present, +// otherwise auto-connects through the internal bridge to Miniflare +// - In dev/production workers: resolved via the request context layer +// +// The underlying `bridgeEnv` from ./bridge/proxy is an internal helper and +// should not be imported by user code. +// ============================================================================= + +import { getContextOrNull } from './runtime/context' +import { bridgeEnv } from './bridge/proxy' + +// DevflareEnv is declared globally by users in their project (env.d.ts) +// This declaration allows TypeScript to pick up the global interface +declare global { + // Default empty interface - users augment this via env.d.ts + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface DevflareEnv {} + // Default empty interface - users augment this via env.d.ts + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface DevflareVars {} +} + +// ----------------------------------------------------------------------------- +// Test Context State (set by createTestContext from devflare/test) +// ----------------------------------------------------------------------------- + +let testContextEnv: Record | null = null +let testContextDispose: (() => Promise) | null = null + +/** + * Called by createTestContext to set up the unified env + * @internal + */ +export function __setTestContext( + envBindings: Record, + dispose: () => Promise +): void { + testContextEnv = envBindings + testContextDispose = dispose +} + +/** + * Called after dispose to clear the test context + * @internal + */ +export function __clearTestContext(): void { + testContextEnv = null + testContextDispose = null +} + +// ----------------------------------------------------------------------------- +// Unified Env Proxy +// ----------------------------------------------------------------------------- + +/** + * Unified environment bindings proxy + * + * Automatically selects the right source: + * - Request context (if inside a request handler) + * - Test context (if set up by createTestContext) + * - Bridge to Miniflare (if outside, in dev mode) + * + * Includes `dispose()` method for cleanup in tests. + * + * @example + * ```ts + * import { env } from 'devflare' + * + * // Works in request handlers + * export default { + * async fetch(request) { + * const value = await env.MY_KV.get('key') + * return new Response(value) + * } + * } + * + * // Also works in tests (after createTestContext) + * beforeAll(() => createTestContext()) + * afterAll(() => env.dispose()) + * test('works', async () => { + * const result = await env.MY_BINDING.method() + * }) + * ``` + */ +export const env: DevflareEnv & { dispose(): Promise } = new Proxy( + {} as DevflareEnv & { dispose(): Promise }, + { + get(_target, prop: string | symbol) { + // Handle dispose() method + if (prop === 'dispose') { + return async () => { + if (testContextDispose) { + await testContextDispose() + __clearTestContext() + } + } + } + + // Try request-scoped context first + const ctx = getContextOrNull() + if (ctx?.env) { + return (ctx.env as Record)[prop as string] + } + + // Try test context next + if (testContextEnv) { + return testContextEnv[prop as string] + } + + // Fall back to bridge env (for standalone usage in dev mode) + return (bridgeEnv as Record)[prop as string] + }, + + has(_target, prop: string | symbol) { + if (prop === 'dispose') return true + + const ctx = getContextOrNull() + if (ctx?.env) { + return prop in (ctx.env as object) + } + if (testContextEnv) { + return prop in testContextEnv + } + return prop in bridgeEnv + }, + + ownKeys(_target) { + const ctx = getContextOrNull() + if (ctx?.env) { + return Reflect.ownKeys(ctx.env as object) + } + if (testContextEnv) { + return Reflect.ownKeys(testContextEnv) + } + return Reflect.ownKeys(bridgeEnv) + }, + + getOwnPropertyDescriptor(_target, prop) { + if (prop === 'dispose') { + return { configurable: true, enumerable: false, writable: false } + } + const ctx = getContextOrNull() + const source = ctx?.env ?? testContextEnv ?? bridgeEnv + return Reflect.getOwnPropertyDescriptor(source as object, prop) + } + } +) + +/** + * Unified runtime variables proxy. + * + * This reads from the same active environment object as {@link env}, but is + * typed from config `vars` via generated `env.d.ts` declarations. It is useful + * for nested typed values authored with `defineConfig({ vars })`. + */ +export const vars: Readonly = new Proxy( + {} as Readonly, + { + get(_target, prop: string | symbol) { + const ctx = getContextOrNull() + if (ctx?.env) { + return (ctx.env as Record)[prop as string] + } + + if (testContextEnv) { + return testContextEnv[prop as string] + } + + return (bridgeEnv as Record)[prop as string] + }, + + has(_target, prop: string | symbol) { + const ctx = getContextOrNull() + if (ctx?.env) { + return prop in (ctx.env as object) + } + if (testContextEnv) { + return prop in testContextEnv + } + return prop in bridgeEnv + }, + + ownKeys(_target) { + const ctx = getContextOrNull() + if (ctx?.env) { + return Reflect.ownKeys(ctx.env as object) + } + if (testContextEnv) { + return Reflect.ownKeys(testContextEnv) + } + return Reflect.ownKeys(bridgeEnv) + }, + + getOwnPropertyDescriptor(_target, prop) { + const ctx = getContextOrNull() + const source = ctx?.env ?? testContextEnv ?? bridgeEnv + return Reflect.getOwnPropertyDescriptor(source as object, prop) + }, + + set(_target, prop) { + throw new TypeError(`Cannot assign to '${String(prop)}' on 'vars' because it is read-only.`) + }, + + deleteProperty(_target, prop) { + throw new TypeError(`Cannot delete property '${String(prop)}' from 'vars' because it is read-only.`) + } + } +) diff --git a/packages/devflare/src/index.ts b/packages/devflare/src/index.ts new file mode 100644 index 0000000..d48a61f --- /dev/null +++ b/packages/devflare/src/index.ts @@ -0,0 +1,54 @@ +// ============================================================================= +// Devflare โ€” Main Package Entry Point +// ============================================================================= +// Config Compiler + CLI Orchestrator for Cloudflare Workers +// ============================================================================= + +// Config utilities +export { + defineConfig, + preview, + loadConfig, + loadResolvedConfig, + compileConfig, + stringifyConfig, + configSchema, + ConfigNotFoundError, + ConfigValidationError, + ConfigResourceResolutionError, + type DevflareConfig, + type DevflareConfigInput, + type PreviewScopeFn, + type PreviewScopeOptions, + type PreviewScopedName, + type PreviewScopedNameOptions, + type LoadResolvedConfigOptions +} from './config' + +// Cross-config referencing +export { + ref, + type RefResult, + type WorkerBinding, + type WorkerBindingAccessor +} from './config' + +// Worker name (build-time injected) +export { workerName } from './workerName' + +// Decorators +export { + durableObject, + getDurableObjectOptions, + type DurableObjectOptions +} from './decorators' + +// CLI +export { runCli, parseArgs } from './cli' +export type { ParsedArgs, CliOptions, CliResult } from './cli' + +// Unified env / vars โ€” tries request context first, falls back to bridge +export { env, vars } from './env' + +// Re-export defineConfig as default for convenience +export { defineConfig as default } from './config' diff --git a/packages/devflare/src/runtime/context-events.ts b/packages/devflare/src/runtime/context-events.ts new file mode 100644 index 0000000..81c7d2e --- /dev/null +++ b/packages/devflare/src/runtime/context-events.ts @@ -0,0 +1,358 @@ +import { wrapEnvSendEmailBindings } from '../utils/send-email' +import type { + DurableObjectAlarmEvent, + DurableObjectFetchEvent, + DurableObjectWebSocketCloseEvent, + DurableObjectWebSocketErrorEvent, + DurableObjectWebSocketMessageEvent, + EmailEvent, + EventContext, + EventInitOptions, + FetchEvent, + FetchEventInit, + QueueEvent, + RuntimeContextValue, + RuntimeEventType, + ScheduledEvent, + TailEvent +} from './context-types' + +function createLocals>(locals?: TLocals): TLocals { + return (locals ?? ({} as TLocals)) as TLocals +} + +/** + * Builds the shared scaffold used by every event builder: a wrapped runtime env + * (with SendEmail bindings proxied) plus a locals bag. Every event type layers + * its specific fields on top of this shell. + */ +function prepareEventShell< + TEnv, + TLocals extends Record = Record +>( + env: TEnv, + options: { locals?: TLocals } = {} +): { env: TEnv; locals: TLocals } { + return { + env: wrapEnvSendEmailBindings(env), + locals: createLocals(options.locals) + } +} + +function createAugmentedTarget( + target: TTarget, + extra: TExtra +): TTarget & TExtra { + return new Proxy(target, { + get(target, prop) { + if (prop in extra) { + return extra[prop as keyof TExtra] + } + + const value = Reflect.get(target, prop, target) + return typeof value === 'function' ? value.bind(target) : value + }, + + has(target, prop) { + return prop in extra || prop in target + }, + + ownKeys(target) { + return Array.from(new Set([ + ...Reflect.ownKeys(target), + ...Reflect.ownKeys(extra) + ])) + }, + + getOwnPropertyDescriptor(target, prop) { + if (prop in extra) { + return { + configurable: true, + enumerable: true, + writable: false, + value: extra[prop as keyof TExtra] + } + } + + return Reflect.getOwnPropertyDescriptor(target, prop) + } + }) as TTarget & TExtra +} + +function createBaseEvent< + TType extends RuntimeEventType, + TEnv = unknown, + TLocals extends Record = Record +>( + type: TType, + env: TEnv, + ctx: RuntimeContextValue, + options: { + locals?: TLocals + request?: Request | null + params?: Record + } = {} +): EventContext { + const shell = prepareEventShell(env, { locals: options.locals }) + + return { + type, + ctx, + ...shell, + request: options.request ?? null, + params: options.params + } +} + +export function createFetchEvent< + TEnv = unknown, + TParams extends Record = Record, + TLocals extends Record = Record +>( + request: Request, + env: TEnv, + ctx: ExecutionContext, + options: FetchEventInit = {} +): FetchEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(request, { + type: 'fetch' as const, + ctx, + ...shell, + url: new URL(request.url), + request, + params: (options.params ?? {}) as TParams + }) as FetchEvent +} + +export function createQueueEvent< + TMessage = unknown, + TEnv = unknown, + TLocals extends Record = Record +>( + batch: MessageBatch, + env: TEnv, + ctx: ExecutionContext, + options: EventInitOptions = {} +): QueueEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(batch, { + type: 'queue' as const, + ctx, + ...shell, + batch + }) as QueueEvent +} + +export function createScheduledEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + controller: ScheduledController, + env: TEnv, + ctx: ExecutionContext, + options: EventInitOptions = {} +): ScheduledEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(controller, { + type: 'scheduled' as const, + ctx, + ...shell, + controller + }) as ScheduledEvent +} + +export function createEmailEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + message: ForwardableEmailMessage, + env: TEnv, + ctx: ExecutionContext, + options: EventInitOptions = {} +): EmailEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(message, { + type: 'email' as const, + ctx, + ...shell, + message + }) as EmailEvent +} + +export function createTailEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + events: TraceItem[], + env: TEnv, + ctx: ExecutionContext, + options: EventInitOptions = {} +): TailEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(events, { + type: 'tail' as const, + ctx, + ...shell, + events + }) as TailEvent +} + +export function createDurableObjectFetchEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + request: Request, + env: TEnv, + state: DurableObjectState, + options: EventInitOptions = {} +): DurableObjectFetchEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(request, { + type: 'durable-object-fetch' as const, + ctx: state, + state, + ...shell, + request + }) as DurableObjectFetchEvent +} + +export function createDurableObjectAlarmEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + env: TEnv, + state: DurableObjectState, + options: EventInitOptions = {} +): DurableObjectAlarmEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return { + type: 'durable-object-alarm', + ctx: state, + state, + ...shell + } as DurableObjectAlarmEvent +} + +export function createDurableObjectWebSocketMessageEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + ws: WebSocket, + message: string | ArrayBuffer, + env: TEnv, + state: DurableObjectState, + options: EventInitOptions = {} +): DurableObjectWebSocketMessageEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(ws, { + type: 'durable-object-websocket-message' as const, + ctx: state, + state, + ...shell, + ws, + message + }) as DurableObjectWebSocketMessageEvent +} + +export function createDurableObjectWebSocketCloseEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean, + env: TEnv, + state: DurableObjectState, + options: EventInitOptions = {} +): DurableObjectWebSocketCloseEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(ws, { + type: 'durable-object-websocket-close' as const, + ctx: state, + state, + ...shell, + ws, + code, + reason, + wasClean + }) as DurableObjectWebSocketCloseEvent +} + +export function createDurableObjectWebSocketErrorEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + ws: WebSocket, + error: unknown, + env: TEnv, + state: DurableObjectState, + options: EventInitOptions = {} +): DurableObjectWebSocketErrorEvent { + const shell = prepareEventShell(env, { locals: options.locals }) + + return createAugmentedTarget(ws, { + type: 'durable-object-websocket-error' as const, + ctx: state, + state, + ...shell, + ws, + error + }) as DurableObjectWebSocketErrorEvent +} + +export function createDefaultEvent< + TEnv = unknown, + TLocals extends Record = Record +>( + env: TEnv, + ctx: RuntimeContextValue, + request: Request | null, + type: RuntimeEventType, + locals: TLocals +): EventContext { + switch (type) { + case 'fetch': { + if (request && ctx) { + return createFetchEvent(request, env, ctx as ExecutionContext, { locals }) + } + return createBaseEvent(type, env, ctx, { locals, request }) + } + case 'durable-object-fetch': { + if (request && ctx) { + return createDurableObjectFetchEvent(request, env, ctx as DurableObjectState, { locals }) + } + return createBaseEvent(type, env, ctx, { locals, request }) + } + case 'durable-object-alarm': { + if (ctx) { + return createDurableObjectAlarmEvent(env, ctx as DurableObjectState, { locals }) + } + return createBaseEvent(type, env, ctx, { locals }) + } + case 'queue': + case 'scheduled': + case 'email': + case 'tail': + case 'durable-object-websocket-message': + case 'durable-object-websocket-close': + case 'durable-object-websocket-error': + // These kinds require a specific payload (batch/controller/message/events/ws) + // that isn't available here โ€” fall back to the minimal base shell. + return createBaseEvent(type, env, ctx, { locals, request }) + default: { + const exhaustive: never = type + throw new Error(`createDefaultEvent: unknown event type ${String(exhaustive)}`) + } + } +} diff --git a/packages/devflare/src/runtime/context-types.ts b/packages/devflare/src/runtime/context-types.ts new file mode 100644 index 0000000..5b7ea22 --- /dev/null +++ b/packages/devflare/src/runtime/context-types.ts @@ -0,0 +1,174 @@ +export type RuntimeEventType = + | 'fetch' + | 'scheduled' + | 'queue' + | 'email' + | 'tail' + | 'durable-object-fetch' + | 'durable-object-alarm' + | 'durable-object-websocket-message' + | 'durable-object-websocket-close' + | 'durable-object-websocket-error' + +export type RuntimeContextValue = ExecutionContext | DurableObjectState | null + +export interface EventContext< + TEnv = unknown, + TLocals extends Record = Record +> { + readonly type: RuntimeEventType + readonly env: TEnv + readonly ctx: RuntimeContextValue + readonly locals: TLocals + readonly request?: Request | null + readonly params?: Record +} + +export type FetchEvent< + TEnv = unknown, + TParams extends Record = Record, + TLocals extends Record = Record +> = Omit & EventContext & { + readonly type: 'fetch' + readonly url: URL + readonly request: Request + readonly ctx: ExecutionContext + readonly params: TParams +} + +export interface QueueEvent< + TMessage = unknown, + TEnv = unknown, + TLocals extends Record = Record +> extends MessageBatch, EventContext { + readonly type: 'queue' + readonly batch: MessageBatch + readonly ctx: ExecutionContext +} + +export interface ScheduledEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends ScheduledController, EventContext { + readonly type: 'scheduled' + readonly controller: ScheduledController + readonly ctx: ExecutionContext +} + +export interface EmailEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends ForwardableEmailMessage, EventContext { + readonly type: 'email' + readonly message: ForwardableEmailMessage + readonly ctx: ExecutionContext +} + +export interface TailEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends Array, EventContext { + readonly type: 'tail' + readonly events: TraceItem[] + readonly ctx: ExecutionContext +} + +export interface DurableObjectEventContext< + TType extends Extract, + TEnv = unknown, + TLocals extends Record = Record +> extends EventContext { + readonly type: TType + readonly ctx: DurableObjectState + readonly state: DurableObjectState +} + +export interface DurableObjectFetchEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends Request, DurableObjectEventContext<'durable-object-fetch', TEnv, TLocals> { + readonly request: Request +} + +export interface DurableObjectAlarmEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends DurableObjectEventContext<'durable-object-alarm', TEnv, TLocals> { } + +export interface DurableObjectWebSocketMessageEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends WebSocket, DurableObjectEventContext<'durable-object-websocket-message', TEnv, TLocals> { + readonly ws: WebSocket + readonly message: string | ArrayBuffer +} + +export interface DurableObjectWebSocketCloseEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends WebSocket, DurableObjectEventContext<'durable-object-websocket-close', TEnv, TLocals> { + readonly ws: WebSocket + readonly code: number + readonly reason: string + readonly wasClean: boolean +} + +export interface DurableObjectWebSocketErrorEvent< + TEnv = unknown, + TLocals extends Record = Record +> extends WebSocket, DurableObjectEventContext<'durable-object-websocket-error', TEnv, TLocals> { + readonly ws: WebSocket + readonly error: unknown +} + +export type DurableObjectEvent< + TEnv = unknown, + TLocals extends Record = Record +> = + | DurableObjectFetchEvent + | DurableObjectAlarmEvent + | DurableObjectWebSocketMessageEvent + | DurableObjectWebSocketCloseEvent + | DurableObjectWebSocketErrorEvent + +export type WorkerEvent< + TEnv = unknown, + TLocals extends Record = Record +> = + | FetchEvent, TLocals> + | QueueEvent + | ScheduledEvent + | EmailEvent + | TailEvent + +export type AnyEvent< + TEnv = unknown, + TLocals extends Record = Record +> = WorkerEvent | DurableObjectEvent + +export interface RequestContext< + TEnv = unknown, + TLocals extends Record = Record +> { + env: TEnv + ctx: RuntimeContextValue + request: Request | null + locals: TLocals + type: RuntimeEventType + event: EventContext +} + +export type EventAccessor = (() => TEvent) & { + safe: () => TEvent | null +} + +export interface EventInitOptions = Record> { + locals?: TLocals +} + +export interface FetchEventInit< + TParams extends Record = Record, + TLocals extends Record = Record +> extends EventInitOptions { + params?: TParams +} diff --git a/packages/devflare/src/runtime/context.ts b/packages/devflare/src/runtime/context.ts new file mode 100644 index 0000000..41f8244 --- /dev/null +++ b/packages/devflare/src/runtime/context.ts @@ -0,0 +1,262 @@ +// ============================================================================= +// Runtime Context โ€” ASL-based context management +// ============================================================================= + +import { AsyncLocalStorage } from 'node:async_hooks' +import { ContextAccessError } from './validation' +import { + createDefaultEvent, + createDurableObjectAlarmEvent, + createDurableObjectFetchEvent, + createDurableObjectWebSocketCloseEvent, + createDurableObjectWebSocketErrorEvent, + createDurableObjectWebSocketMessageEvent, + createEmailEvent, + createFetchEvent, + createQueueEvent, + createScheduledEvent, + createTailEvent +} from './context-events' +import type { + DurableObjectAlarmEvent, + DurableObjectEvent, + DurableObjectFetchEvent, + DurableObjectWebSocketCloseEvent, + DurableObjectWebSocketErrorEvent, + DurableObjectWebSocketMessageEvent, + EmailEvent, + EventAccessor, + EventContext, + FetchEvent, + QueueEvent, + RequestContext, + ScheduledEvent, + TailEvent +} from './context-types' + +export { + createFetchEvent, + createQueueEvent, + createScheduledEvent, + createEmailEvent, + createTailEvent, + createDurableObjectFetchEvent, + createDurableObjectAlarmEvent, + createDurableObjectWebSocketMessageEvent, + createDurableObjectWebSocketCloseEvent, + createDurableObjectWebSocketErrorEvent +} from './context-events' + +export type { + AnyEvent, + DurableObjectAlarmEvent, + DurableObjectEvent, + DurableObjectEventContext, + DurableObjectFetchEvent, + DurableObjectWebSocketCloseEvent, + DurableObjectWebSocketErrorEvent, + DurableObjectWebSocketMessageEvent, + EmailEvent, + EventContext, + EventInitOptions, + FetchEvent, + FetchEventInit, + QueueEvent, + RequestContext, + RuntimeContextValue, + RuntimeEventType, + ScheduledEvent, + TailEvent, + WorkerEvent +} from './context-types' + +const storage = new AsyncLocalStorage() + +function createLocals>(): TLocals { + return {} as TLocals +} + +export function runWithContext( + env: TEnv, + ctx: ExecutionContext | DurableObjectState | null, + request: Request | null, + fn: () => T, + type: RequestContext['type'] = 'fetch' +): T { + const locals = createLocals>() + const event = createDefaultEvent(env, ctx, request, type, locals) + + return runWithEventContext(event as EventContext, fn) +} + +export function runWithEventContext< + T, + TEnv = unknown, + TLocals extends Record = Record +>( + event: EventContext, + fn: () => T +): T { + const context: RequestContext = { + env: event.env, + ctx: event.ctx, + request: event.request ?? null, + locals: event.locals, + type: event.type, + event + } + + return storage.run(context, fn) +} + +export function getContext< + TEnv = unknown, + TLocals extends Record = Record +>(): RequestContext { + const context = storage.getStore() + if (!context) { + throw new ContextUnavailableError() + } + + return context as RequestContext +} + +export function getContextOrNull< + TEnv = unknown, + TLocals extends Record = Record +>(): RequestContext | null { + const context = storage.getStore() + return (context ?? null) as RequestContext | null +} + +export function getEventContext< + TEnv = unknown, + TLocals extends Record = Record +>(): EventContext { + return getContext().event +} + +export function getEventContextOrNull< + TEnv = unknown, + TLocals extends Record = Record +>(): EventContext | null { + return getContextOrNull()?.event ?? null +} + +export function hasContext(): boolean { + return storage.getStore() !== undefined +} + +function createEventAccessor( + name: string, + matcher: (event: EventContext) => event is TEvent +): EventAccessor { + const accessor = (() => { + const currentEvent = getEventContextOrNull() + + if (!currentEvent) { + throw new ContextUnavailableError() + } + + if (!matcher(currentEvent)) { + throw new ContextUnavailableError( + `${name} is not available in the current '${currentEvent.type}' context. + +Devflare stores event objects in AsyncLocalStorage so helpers called within a handler can reach the active event. +Use ${name}.safe() to return null instead of throwing, or call the getter that matches the active surface.` + ) + } + + return currentEvent + }) as EventAccessor + + accessor.safe = () => { + const currentEvent = getEventContextOrNull() + return currentEvent && matcher(currentEvent) ? currentEvent : null + } + + return accessor +} + +function isFetchEvent(event: EventContext): event is FetchEvent { + return event.type === 'fetch' && event.request instanceof Request +} + +function isQueueEvent(event: EventContext): event is QueueEvent { + return event.type === 'queue' && 'batch' in event +} + +function isScheduledEvent(event: EventContext): event is ScheduledEvent { + return event.type === 'scheduled' && 'controller' in event +} + +function isEmailEvent(event: EventContext): event is EmailEvent { + return event.type === 'email' && 'message' in event +} + +function isTailEvent(event: EventContext): event is TailEvent { + return event.type === 'tail' && Array.isArray(event) && 'events' in event +} + +function isDurableObjectEvent(event: EventContext): event is DurableObjectEvent { + return event.type.startsWith('durable-object-') && 'state' in event +} + +function isDurableObjectFetchEvent(event: EventContext): event is DurableObjectFetchEvent { + return event.type === 'durable-object-fetch' && event.request instanceof Request +} + +function isDurableObjectAlarmEvent(event: EventContext): event is DurableObjectAlarmEvent { + return event.type === 'durable-object-alarm' && 'state' in event +} + +function isDurableObjectWebSocketMessageEvent(event: EventContext): event is DurableObjectWebSocketMessageEvent { + return event.type === 'durable-object-websocket-message' && 'ws' in event && 'message' in event +} + +function isDurableObjectWebSocketCloseEvent(event: EventContext): event is DurableObjectWebSocketCloseEvent { + return event.type === 'durable-object-websocket-close' && 'ws' in event && 'code' in event +} + +function isDurableObjectWebSocketErrorEvent(event: EventContext): event is DurableObjectWebSocketErrorEvent { + return event.type === 'durable-object-websocket-error' && 'ws' in event && 'error' in event +} + +export const getFetchEvent = createEventAccessor('getFetchEvent()', isFetchEvent) +export const getQueueEvent = createEventAccessor('getQueueEvent()', isQueueEvent) +export const getScheduledEvent = createEventAccessor('getScheduledEvent()', isScheduledEvent) +export const getEmailEvent = createEventAccessor('getEmailEvent()', isEmailEvent) +export const getTailEvent = createEventAccessor('getTailEvent()', isTailEvent) +export const getDurableObjectEvent = createEventAccessor('getDurableObjectEvent()', isDurableObjectEvent) +export const getDurableObjectFetchEvent = createEventAccessor('getDurableObjectFetchEvent()', isDurableObjectFetchEvent) +export const getDurableObjectAlarmEvent = createEventAccessor('getDurableObjectAlarmEvent()', isDurableObjectAlarmEvent) +export const getDurableObjectWebSocketMessageEvent = createEventAccessor('getDurableObjectWebSocketMessageEvent()', isDurableObjectWebSocketMessageEvent) +export const getDurableObjectWebSocketCloseEvent = createEventAccessor('getDurableObjectWebSocketCloseEvent()', isDurableObjectWebSocketCloseEvent) +export const getDurableObjectWebSocketErrorEvent = createEventAccessor('getDurableObjectWebSocketErrorEvent()', isDurableObjectWebSocketErrorEvent) + +/** + * @deprecated Prefer {@link ContextAccessError} from `./validation`. This name + * is retained as a subclass for backward compatibility โ€” both names refer to + * the same context-unavailable failure mode and `instanceof ContextAccessError` + * is true for any instance. + */ +export class ContextUnavailableError extends ContextAccessError { + readonly code = 'CONTEXT_UNAVAILABLE' + + constructor(message?: string) { + super('context', '') + if (message !== undefined) { + this.message = message + } else { + this.message = + `Context not available. Devflare uses AsyncLocalStorage to carry the active event through fetch, queue, scheduled, email, tail, and Durable Object handler call chains.\n\n` + + `This usually means one of:\n\n` + + `1. Accessing context at module top-level (runs at cold start, not per-request)\n` + + `2. Accessing context in setTimeout/setInterval callbacks\n` + + `3. Missing 'nodejs_compat' compatibility flag in your worker config\n\n` + + `Fix: Move the access inside your handler, middleware, or a helper called from that handler trail.\n` + + `Learn more: https://devflare.dev/docs/context-errors` + } + this.name = 'ContextUnavailableError' + } +} diff --git a/packages/devflare/src/runtime/exports.ts b/packages/devflare/src/runtime/exports.ts new file mode 100644 index 0000000..029d1e8 --- /dev/null +++ b/packages/devflare/src/runtime/exports.ts @@ -0,0 +1,199 @@ +// ============================================================================= +// Runtime Exports โ€” Type-safe request-scoped context access +// ============================================================================= +// These proxies provide ergonomic access to Cloudflare Worker context +// with helpful error messages when accessed outside AsyncLocalStorage-backed +// handler trails +// ============================================================================= + +import { getContextOrNull, type EventContext, type RuntimeContextValue } from './context' +import { createContextProxy } from './validation' +// Side-effect import to ensure the canonical `declare global { interface +// DevflareEnv {} }` from `src/env.ts` is loaded so the type used below +// resolves to the same global users augment via their `env.d.ts`. +import '../env' + +// ============================================================================= +// Readonly Proxy Helper +// ============================================================================= + +/** + * Creates a readonly proxy that throws on mutation attempts. Thin wrapper + * over {@link createContextProxy} with `mutable: false`. + */ +function createReadonlyProxy( + getter: () => T | null | undefined, + name: string +): Readonly { + return createContextProxy(getter, name, { mutable: false }) as Readonly +} + +// ============================================================================= +// Environment Bindings (env) +// ============================================================================= + +/** + * Access environment bindings (KV, D1, R2, etc.) and variables + * + * @remarks + * This is readonly - bindings cannot be reassigned at runtime. + * Available only within an active Devflare-managed handler or middleware call trail. + * + * @example + * ```ts + * import { env, type FetchEvent } from 'devflare/runtime' + * + * export async function fetch(event: FetchEvent) { + * const value = await env.MY_KV.get('key') + * const dbResult = await env.DB.prepare('SELECT * FROM users').all() + * return new Response(JSON.stringify({ + * path: event.url.pathname, + * value, + * dbResult + * })) + * } + * ``` + * + * @throws {ContextAccessError} When accessed outside an active Devflare-managed handler trail + */ +export const env: Readonly = createReadonlyProxy( + () => getContextOrNull()?.env as Record | undefined, + 'env' +) + +// ============================================================================= +// Runtime Variables (vars) +// ============================================================================= + +/** + * Access typed runtime variables declared with `defineConfig({ vars })`. + * + * @remarks + * This proxy reads from the active Worker environment object, just like + * {@link env}, but its type is generated from the `vars` config lane. Nested + * values are preserved, so `vars.mongo.database` works when config declares a + * nested object. + * + * @example + * ```ts + * import { vars } from 'devflare/runtime' + * + * export async function fetch() { + * return Response.json({ + * database: vars.mongo.database + * }) + * } + * ``` + * + * @throws {ContextAccessError} When accessed outside an active Devflare-managed handler trail + */ +export const vars: Readonly = createReadonlyProxy( + () => getContextOrNull()?.env as Record | undefined, + 'vars' +) + +// ============================================================================= +// Execution Context (ctx) +// ============================================================================= + +/** + * Access the ExecutionContext for background tasks + * + * @remarks + * Provides `waitUntil()` for background processing and + * `passThroughOnException()` for error handling on worker surfaces. + * When running inside a Durable Object, this proxy exposes the current + * `DurableObjectState` instead. + * This is readonly. + * + * @example + * ```ts + * import { ctx, type FetchEvent } from 'devflare/runtime' + * + * export async function fetch(event: FetchEvent) { + * const response = new Response('OK') + * ctx.waitUntil(analytics.track(event.url.pathname)) + * return response + * } + * ``` + * + * @throws {ContextAccessError} When accessed outside an active Devflare-managed handler trail + */ +export const ctx: Readonly = createReadonlyProxy( + () => getContextOrNull()?.ctx as RuntimeContextValue | undefined, + 'ctx' +) + +// ============================================================================= +// Event Context (event) +// ============================================================================= + +/** + * Access the current event object. + * + * @remarks + * This is the generic event proxy for the active AsyncLocalStorage context. + * + * For strong per-surface typing, prefer `getFetchEvent()`, `getQueueEvent()`, + * `getScheduledEvent()`, `getEmailEvent()`, and the Durable Object getters + * from `devflare/runtime`. + * + * @example + * ```ts + * import { event as runtimeEvent, type FetchEvent, type ScheduledEvent } from 'devflare/runtime' + * + * export async function fetch(event: FetchEvent) { + * console.log(runtimeEvent.type) + * console.log(event.url.pathname) + * } + * + * export async function scheduled(event: ScheduledEvent) { + * console.log(runtimeEvent.type) + * console.log(event.cron) + * } + * ``` + * + * @throws {ContextAccessError} When accessed outside an active Devflare-managed handler trail + */ +export const event: Readonly = createReadonlyProxy( + () => getContextOrNull()?.event, + 'event' +) + +// ============================================================================= +// Request-Scoped Locals (locals) +// ============================================================================= + +/** + * Mutable request-scoped storage for sharing data between middleware + * + * @remarks + * Unlike `env` and `ctx`, locals can be mutated. Each request gets + * a fresh locals object. Use this for: + * - Authentication state + * - Parsed request data + * - Computed values shared across middleware + * + * @example + * ```ts + * import { locals, type FetchEvent } from 'devflare/runtime' + * + * // In auth middleware + * const authMiddleware = async (event: FetchEvent, next: () => Promise) => { + * locals.user = await validateToken(event.request?.headers.get('Authorization')) + * return next() + * } + * + * // In handler + * export async function fetch(event: FetchEvent) { + * void event + * console.log(locals.user) + * } + * ``` + * + * @throws {ContextAccessError} When accessed outside an active Devflare-managed handler trail + */ +export const locals: Record = createContextProxy( + () => getContextOrNull()?.locals, + 'locals' +) diff --git a/packages/devflare/src/runtime/index.ts b/packages/devflare/src/runtime/index.ts new file mode 100644 index 0000000..792beba --- /dev/null +++ b/packages/devflare/src/runtime/index.ts @@ -0,0 +1,122 @@ +// ============================================================================= +// Runtime Module โ€” Public Exports +// ============================================================================= +// This module is safe to import in worker code and is the preferred runtime +// entry for request-scoped helpers such as env/ctx/event/locals. +// +// It intentionally excludes CLI, Miniflare orchestration, build/deploy, and +// other Node-side tooling exports. +// ============================================================================= + +// Request-scoped runtime proxies +export { + env, + vars, + ctx, + event, + locals +} from './exports' + +export type { EventContext } from './context' + +// Context management +export { + createFetchEvent, + createQueueEvent, + createScheduledEvent, + createEmailEvent, + createTailEvent, + createDurableObjectFetchEvent, + createDurableObjectAlarmEvent, + createDurableObjectWebSocketMessageEvent, + createDurableObjectWebSocketCloseEvent, + createDurableObjectWebSocketErrorEvent, + runWithContext, + runWithEventContext, + getContext, + getContextOrNull, + getEventContext, + getEventContextOrNull, + getFetchEvent, + getQueueEvent, + getScheduledEvent, + getEmailEvent, + getTailEvent, + getDurableObjectEvent, + getDurableObjectFetchEvent, + getDurableObjectAlarmEvent, + getDurableObjectWebSocketMessageEvent, + getDurableObjectWebSocketCloseEvent, + getDurableObjectWebSocketErrorEvent, + hasContext, + ContextUnavailableError, + type RuntimeEventType, + type RuntimeContextValue, + type RequestContext +} from './context' + +export type { + FetchEvent, + QueueEvent, + ScheduledEvent, + EmailEvent, + TailEvent, + DurableObjectEvent, + DurableObjectFetchEvent, + DurableObjectAlarmEvent, + DurableObjectWebSocketMessageEvent, + DurableObjectWebSocketCloseEvent, + DurableObjectWebSocketErrorEvent, + WorkerEvent, + AnyEvent +} from './context' + +// Validation utilities (safe for workers) +export { createContextProxy, ContextAccessError } from './validation' + +// Middleware system (safe for workers) +export { + sequence, + resolveFetchHandler, + invokeFetchHandler, + createResolveFetch, + invokeFetchModule, + defineFetchHandler, + defineQueueHandler, + defineScheduledHandler, + markResolveStyle, + markWorkerStyle, + assertExplicitQueueHandlerStyle, + assertExplicitScheduledHandlerStyle, + type Awaitable, + type ResolveFetch, + type FetchMiddleware +} from './middleware' + +export { + matchFetchRoute, + invokeRouteModules, + createRouteResolve +} from './router' + +export type { + RouteSegment, + RouteModuleDefinition, + RouteMatchResult +} from './router/types' + +// Decorators (safe for workers) +export { + durableObject, + getDurableObjectOptions, + type DurableObjectOptions +} from '../decorators' + +// Local sendEmail bindings (worker-safe: pure in-worker state; no Node imports) +// Kept on the runtime barrel because the generated composed worker imports them +// here and the runtime entry is the reliable resolution path inside bundled workers. +export { + setLocalSendEmailBindings, + clearLocalSendEmailBindings, + type LocalSendEmailBindingConfig +} from '../utils/send-email' diff --git a/packages/devflare/src/runtime/middleware.ts b/packages/devflare/src/runtime/middleware.ts new file mode 100644 index 0000000..7cda653 --- /dev/null +++ b/packages/devflare/src/runtime/middleware.ts @@ -0,0 +1,580 @@ +// ============================================================================= +// Middleware System โ€” Composable request handling +// ============================================================================= + +import { runWithEventContext, type FetchEvent } from './context' + +type AnyFunction = (...args: any[]) => any +type FetchModule = Record + +const FETCH_SEQUENCE_SYMBOL = Symbol.for('devflare.fetch-sequence') +const FETCH_RESOLVE_STYLE_SYMBOL = Symbol.for('devflare.fetch-resolve-style') +const FETCH_WORKER_STYLE_SYMBOL = Symbol.for('devflare.fetch-worker-style') +const QUEUE_WORKER_STYLE_SYMBOL = Symbol.for('devflare.queue-worker-style') +const SCHEDULED_WORKER_STYLE_SYMBOL = Symbol.for('devflare.scheduled-worker-style') + +/** + * Promise-or-value helper used by worker-safe runtime APIs. + */ +export type Awaitable = T | Promise + +/** + * Resolve the next request-wide middleware or module-local leaf handler. + * + * Passing a new event mirrors SvelteKit's `resolve(event)` pattern and lets + * middleware continue the chain with a modified request context. + */ +export type ResolveFetch = (event?: TEvent) => Promise + +/** + * Request-wide fetch middleware. + * + * These are intended for the single module-level fetch entry export such as: + * - `export const fetch = sequence(corsHandle, appFetch)` + * - `export const handle = sequence(corsHandle, appFetch)` + * + * `fetch` and `handle` are aliases for the same primary fetch entry, so a + * module should export one or the other, not both. + */ +export type FetchMiddleware = ( + event: TEvent, + resolve: ResolveFetch +) => Awaitable + +interface FetchResolveOptions { + fallbackResolve?: ResolveFetch +} + +function createNotFoundResponse(): Response { + return new Response('Not Found', { status: 404 }) +} + +function isFunction(value: unknown): value is AnyFunction { + return typeof value === 'function' +} + +/** + * Tag a handler as resolve-style: `(event, resolve) => Response`. + * + * Attaches `FETCH_RESOLVE_STYLE_SYMBOL` so detection survives minification + * (which rewrites parameter names and function source output). + */ +export function markResolveStyle(handler: T): T { + Object.defineProperty(handler, FETCH_RESOLVE_STYLE_SYMBOL, { + value: true, + enumerable: false, + configurable: true, + writable: false + }) + + return handler +} + +/** + * Tag a handler as worker-style: `(request, env)` or `(request, env, ctx)`. + * + * Required for 2-argument worker-style handlers because devflare can no + * longer disambiguate `(event, resolve)` vs `(request, env)` from arity + * alone โ€” the parameter-name fallback was removed in R1-strict. + */ +export function markWorkerStyle(handler: T): T { + Object.defineProperty(handler, FETCH_WORKER_STYLE_SYMBOL, { + value: true, + enumerable: false, + configurable: true, + writable: false + }) + + return handler +} + +/** + * Explicit escape hatch for declaring a handler's calling convention. + * + * - `options.style === 'resolve'` marks the handler so it is dispatched as + * `(event, resolve) => Response`. + * - `options.style === 'worker'` marks the handler so it is dispatched as + * `(request, env[, ctx]) => Response`. + * + * The marker is required for 2-argument handlers; 1-arg `(event)` and + * 3-arg `(request, env, ctx)` handlers do not need to be wrapped. + */ +export function defineFetchHandler( + handler: T, + options?: { style?: 'resolve' | 'worker' } +): T { + if (options?.style === 'resolve') { + return markResolveStyle(handler) + } + + if (options?.style === 'worker') { + return markWorkerStyle(handler) + } + + return handler +} + +function hasResolveStyleMarker(handler: AnyFunction): boolean { + const record = handler as unknown as Record + return Boolean(record[FETCH_RESOLVE_STYLE_SYMBOL] || record[FETCH_SEQUENCE_SYMBOL]) +} + +function hasWorkerStyleMarker(handler: AnyFunction): boolean { + const record = handler as unknown as Record + return Boolean(record[FETCH_WORKER_STYLE_SYMBOL]) +} + +/** + * Detect resolve-style `(event, resolve) => Response` handlers. + * + * Symbol-based only โ€” the previous parameter-name fallback was removed in + * R1-strict. Callers that rely on a 2-argument resolve-style handler must + * wrap it with `markResolveStyle`, `defineFetchHandler({ style: 'resolve' })`, + * or compose it via `sequence(...)`. + */ +function isResolveStyleFunction(handler: AnyFunction): boolean { + return hasResolveStyleMarker(handler) +} + +/** + * Throw a clear error for ambiguous unmarked 2-argument fetch handlers. + * + * In R1-strict, devflare no longer guesses the calling convention from + * parameter names. 2-arg handlers must be marked with + * `defineFetchHandler(fn, { style: 'resolve' | 'worker' })` (or wrapped via + * `markResolveStyle` / `markWorkerStyle` / `sequence(...)`) so dispatch is + * unambiguous and minification-safe. + */ +function assertExplicit2ArgStyle(handler: AnyFunction): void { + if (handler.length !== 2) { + return + } + + if (hasResolveStyleMarker(handler) || hasWorkerStyleMarker(handler)) { + return + } + + throw new Error( + '[devflare] Ambiguous 2-argument fetch handler. The calling convention must be declared explicitly via ' + + "`defineFetchHandler(fn, { style: 'resolve' })` (for `(event, resolve) => Response`) or " + + "`defineFetchHandler(fn, { style: 'worker' })` (for `(request, env) => Response`). " + + 'Single-arg `(event) => Response` and 3-arg worker-style `(request, env, ctx) => Response` ' + + 'handlers do not require wrapping.' + ) +} + +/** + * Tag a queue handler as worker-style: `(batch, env)` or `(batch, env, ctx)`. + * + * Required for 2-argument worker-style queue handlers because devflare can + * no longer disambiguate `(event)` vs `(batch, env)` from arity alone in + * R1-strict. + */ +export function defineQueueHandler(handler: T): T { + Object.defineProperty(handler, QUEUE_WORKER_STYLE_SYMBOL, { + value: true, + enumerable: false, + configurable: true, + writable: false + }) + + return handler +} + +/** + * Tag a scheduled handler as worker-style: `(controller, env)` or + * `(controller, env, ctx)`. + */ +export function defineScheduledHandler(handler: T): T { + Object.defineProperty(handler, SCHEDULED_WORKER_STYLE_SYMBOL, { + value: true, + enumerable: false, + configurable: true, + writable: false + }) + + return handler +} + +function hasQueueWorkerStyleMarker(handler: AnyFunction): boolean { + const record = handler as unknown as Record + return Boolean(record[QUEUE_WORKER_STYLE_SYMBOL]) +} + +function hasScheduledWorkerStyleMarker(handler: AnyFunction): boolean { + const record = handler as unknown as Record + return Boolean(record[SCHEDULED_WORKER_STYLE_SYMBOL]) +} + +/** + * Throw a clear error for ambiguous unmarked 2-argument queue handlers. + * + * Mirrors `assertExplicit2ArgStyle` for the queue surface. 2-arg handlers + * must be wrapped with `defineQueueHandler(fn)` so dispatch is unambiguous + * and minification-safe; 1-arg `(event)` and 3-arg `(batch, env, ctx)` do + * not require wrapping. + */ +export function assertExplicitQueueHandlerStyle(handler: AnyFunction): void { + if (handler.length !== 2) { + return + } + + if (hasQueueWorkerStyleMarker(handler)) { + return + } + + throw new Error( + '[devflare] Ambiguous 2-argument queue handler. The calling convention must be declared explicitly via ' + + '`defineQueueHandler(fn)` for `(batch, env) => void` worker-style handlers. ' + + 'Single-arg `(event) => void` and 3-arg `(batch, env, ctx) => void` handlers do not require wrapping.' + ) +} + +/** + * Throw a clear error for ambiguous unmarked 2-argument scheduled handlers. + */ +export function assertExplicitScheduledHandlerStyle(handler: AnyFunction): void { + if (handler.length !== 2) { + return + } + + if (hasScheduledWorkerStyleMarker(handler)) { + return + } + + throw new Error( + '[devflare] Ambiguous 2-argument scheduled handler. The calling convention must be declared explicitly via ' + + '`defineScheduledHandler(fn)` for `(controller, env) => void` worker-style handlers. ' + + 'Single-arg `(event) => void` and 3-arg `(controller, env, ctx) => void` handlers do not require wrapping.' + ) +} + +/** + * Detect Cloudflare Worker-style `fetch(request, env[, ctx])` handlers. + * + * Returns true when: + * - arity is `>= 3` (unambiguous worker signature), or + * - the handler is explicitly marked worker-style via `markWorkerStyle` or + * `defineFetchHandler({ style: 'worker' })`. + * + * In R1-strict, 2-argument worker-style handlers must be marked โ€” there is + * no parameter-name fallback. Unmarked 2-arg handlers throw via + * `assertExplicit2ArgStyle` when invoked. + */ +function isWorkerStyleFetchFunction(handler: AnyFunction): boolean { + if (isResolveStyleFunction(handler)) { + return false + } + + if (handler.length >= 3) { + return true + } + + return hasWorkerStyleMarker(handler) +} + +function invokeWorkerStyleFetchFunction( + handler: AnyFunction, + event: TEvent +): Promise | Response | null { + return handler(event.request, event.env, event.ctx) +} + +function bindMethod(target: unknown, key: string): AnyFunction | null { + if (!target || typeof target !== 'object') { + return null + } + + const value = (target as Record)[key] + if (!isFunction(value)) { + return null + } + + const boundHandler = value.bind(target) + if (isResolveStyleFunction(value)) { + markResolveStyle(boundHandler) + } + if (hasWorkerStyleMarker(value)) { + markWorkerStyle(boundHandler) + } + return boundHandler +} + +function createFetchSequence( + middlewares: FetchMiddleware[] +): FetchMiddleware { + return async ( + event: TEvent, + resolve: ResolveFetch = async () => createNotFoundResponse() + ): Promise => { + const executeMiddleware = async (index: number, activeEvent: TEvent): Promise => { + if (index >= middlewares.length) { + return resolve(activeEvent) + } + + const middleware = middlewares[index] + return middleware(activeEvent, async (nextEvent = activeEvent) => { + return executeMiddleware(index + 1, nextEvent) + }) + } + + return executeMiddleware(0, event) + } +} + +/** + * Compose request-wide middleware into a single fetch surface. + */ +export function sequence( + ...middlewares: FetchMiddleware[] +): FetchMiddleware { + const composed = createFetchSequence(middlewares) + + Object.defineProperty(composed, FETCH_SEQUENCE_SYMBOL, { + value: true, + enumerable: false, + configurable: false, + writable: false + }) + + return markResolveStyle(composed) +} + +function getDefaultHandleHandler(module: FetchModule): AnyFunction | null { + return bindMethod(module.default, 'handle') +} + +function getDefaultFetchHandler(module: FetchModule): AnyFunction | null { + const defaultExport = module.default + + if (isFunction(defaultExport)) { + return defaultExport + } + + return bindMethod(defaultExport, 'fetch') +} + +interface PrimaryFetchEntryCandidate { + name: string + handler: AnyFunction +} + +function getPrimaryFetchEntryCandidates(module: FetchModule): PrimaryFetchEntryCandidate[] { + const candidates: PrimaryFetchEntryCandidate[] = [] + + const namedHandle = isFunction(module.handle) ? module.handle : null + if (namedHandle) { + candidates.push({ + name: 'handle', + handler: namedHandle + }) + } + + const namedFetch = isFunction(module.fetch) ? module.fetch : null + if (namedFetch) { + candidates.push({ + name: 'fetch', + handler: namedFetch + }) + } + + const defaultHandle = getDefaultHandleHandler(module) + if (defaultHandle) { + candidates.push({ + name: 'default.handle', + handler: defaultHandle + }) + } + + const defaultFetch = getDefaultFetchHandler(module) + if (defaultFetch) { + candidates.push({ + name: isFunction(module.default) ? 'default' : 'default.fetch', + handler: defaultFetch + }) + } + + return candidates +} + +function assertSinglePrimaryFetchEntry(candidates: PrimaryFetchEntryCandidate[]): void { + if (candidates.length <= 1) { + return + } + + const foundEntries = candidates.map(({ name }) => `"${name}"`).join(', ') + throw new Error( + `Ambiguous fetch entry module. Export exactly one primary fetch entry per module. ` + + `Use either "fetch" or "handle" (or one default equivalent), not both. ` + + `Found: ${foundEntries}` + ) +} + +interface MethodResolution { + handler: AnyFunction + stripBody: boolean +} + +function resolveMethodHandler(module: FetchModule, method: string): MethodResolution | null { + const normalizedMethod = method.toUpperCase() + const directHandler = (isFunction(module[normalizedMethod]) + ? module[normalizedMethod] + : bindMethod(module.default, normalizedMethod)) as AnyFunction | null + + if (directHandler) { + return { + handler: directHandler, + stripBody: false + } + } + + if (normalizedMethod === 'HEAD') { + const getHandler = (isFunction(module.GET) + ? module.GET + : bindMethod(module.default, 'GET')) as AnyFunction | null + + if (getHandler) { + return { + handler: getHandler, + stripBody: true + } + } + } + + const allHandler = (isFunction(module.ALL) + ? module.ALL + : bindMethod(module.default, 'ALL')) as AnyFunction | null + + if (allHandler) { + return { + handler: allHandler, + stripBody: false + } + } + + return null +} + +async function invokeResolvedFetchHandler( + handler: AnyFunction, + event: TEvent +): Promise { + if (isResolveStyleFunction(handler)) { + return handler(event, async () => createNotFoundResponse()) + } + + if (isWorkerStyleFetchFunction(handler)) { + return invokeWorkerStyleFetchFunction(handler, event) + } + + assertExplicit2ArgStyle(handler) + return handler(event) +} + +/** + * Resolve the primary fetch surface for a module. + * + * `fetch` and `handle` are treated as aliases for the same primary fetch + * entry. Exporting more than one primary entry from the same module is + * rejected as ambiguous. + */ +export function resolveFetchHandler(module: FetchModule): AnyFunction | null { + const candidates = getPrimaryFetchEntryCandidates(module) + assertSinglePrimaryFetchEntry(candidates) + return candidates[0]?.handler ?? null +} + +/** + * Invoke a fetch entry handler with the supported calling conventions. + * + * This supports: + * - `fetch(request)` + * - `fetch(request, env)` + * - `fetch(request, env, ctx)` + * - `fetch(event)` + * - `fetch(event, resolve)` / `handle(event, resolve)` + */ +export async function invokeFetchHandler( + handler: unknown, + event: TEvent, + resolve: ResolveFetch = async () => createNotFoundResponse() +): Promise { + if (!isFunction(handler)) { + return resolve(event) + } + + if (isResolveStyleFunction(handler)) { + const response = await handler(event, resolve) + return response ?? createNotFoundResponse() + } + + if (isWorkerStyleFetchFunction(handler)) { + const response = await invokeWorkerStyleFetchFunction(handler, event) + return response ?? createNotFoundResponse() + } + + assertExplicit2ArgStyle(handler) + const response = await handler(event) + return response ?? createNotFoundResponse() +} + +/** + * Create a SvelteKit-style `resolve(event)` callback for a fetch module. + * + * Resolution order is: + * - matching HTTP method export such as `GET()` / `POST()` / `ALL()` + * - 404 response + */ +export function createResolveFetch( + module: FetchModule, + _currentEntry: unknown, + initialEvent: TEvent, + options: FetchResolveOptions = {} +): ResolveFetch { + return async (nextEvent = initialEvent): Promise => { + return runWithEventContext(nextEvent, async () => { + const methodResolution = resolveMethodHandler(module, nextEvent.request.method) + if (methodResolution) { + const response = await invokeResolvedFetchHandler(methodResolution.handler, nextEvent) + const finalResponse = response ?? createNotFoundResponse() + + if (methodResolution.stripBody) { + return new Response(null, finalResponse) + } + + return finalResponse + } + + if (options.fallbackResolve) { + return options.fallbackResolve(nextEvent) + } + + return createNotFoundResponse() + }) + } +} + +/** + * Invoke the resolved fetch surface for a module. + * + * This lets runtime wrappers support a single request-wide `handle` or + * `fetch` export, default exports, and same-module method handlers such as + * `GET()`. + */ +export async function invokeFetchModule( + module: FetchModule, + event: TEvent, + fallbackResolve?: ResolveFetch +): Promise { + const handler = resolveFetchHandler(module) + + if (!handler) { + return createResolveFetch(module, null, event, { fallbackResolve })(event) + } + + return invokeFetchHandler( + handler, + event, + createResolveFetch(module, handler, event, { fallbackResolve }) + ) +} diff --git a/packages/devflare/src/runtime/router/index.ts b/packages/devflare/src/runtime/router/index.ts new file mode 100644 index 0000000..80dce72 --- /dev/null +++ b/packages/devflare/src/runtime/router/index.ts @@ -0,0 +1,156 @@ +// ============================================================================= +// Runtime File Router +// ============================================================================= + +import type { RouteMatchResult, RouteModuleDefinition, RouteSegment } from './types' +import { createFetchEvent, runWithEventContext, type FetchEvent } from '../context' +import { invokeFetchModule, type ResolveFetch } from '../middleware' + +function normalizePathname(pathname: string): string { + if (!pathname || pathname === '/') { + return '/' + } + + const normalized = pathname.startsWith('/') ? pathname : `/${pathname}` + const trimmed = normalized.replace(/\/+$|\/+$/g, '') + return trimmed === '' ? '/' : trimmed +} + +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +function getPathSegments(pathname: string): string[] { + const normalizedPathname = normalizePathname(pathname) + if (normalizedPathname === '/') { + return [] + } + + return normalizedPathname + .slice(1) + .split('/') + .filter(Boolean) + .map(decodePathSegment) +} + +function getMatchPathname(input: Request | URL | string): string { + if (input instanceof Request) { + return new URL(input.url).pathname + } + + if (input instanceof URL) { + return input.pathname + } + + if (input.includes('://')) { + return new URL(input).pathname + } + + return input +} + +function matchRouteSegments( + routeSegments: readonly RouteSegment[], + pathnameSegments: readonly string[] +): Record | null { + if (routeSegments.length === 0) { + return pathnameSegments.length === 0 ? {} : null + } + + const params: Record = {} + let routeIndex = 0 + let pathIndex = 0 + + while (routeIndex < routeSegments.length) { + const routeSegment = routeSegments[routeIndex] + + if (routeSegment.type === 'optional-rest') { + params[routeSegment.name] = pathnameSegments.slice(pathIndex).join('/') + pathIndex = pathnameSegments.length + routeIndex += 1 + continue + } + + if (routeSegment.type === 'rest') { + if (pathIndex >= pathnameSegments.length) { + return null + } + + params[routeSegment.name] = pathnameSegments.slice(pathIndex).join('/') + pathIndex = pathnameSegments.length + routeIndex += 1 + continue + } + + const pathnameSegment = pathnameSegments[pathIndex] + if (pathnameSegment === undefined) { + return null + } + + if (routeSegment.type === 'static') { + if (pathnameSegment !== routeSegment.value) { + return null + } + } else { + params[routeSegment.name] = pathnameSegment + } + + pathIndex += 1 + routeIndex += 1 + } + + if (pathIndex !== pathnameSegments.length) { + return null + } + + return params +} + +export function matchFetchRoute( + routes: readonly RouteModuleDefinition[], + input: Request | URL | string +): RouteMatchResult | null { + const pathnameSegments = getPathSegments(getMatchPathname(input)) + + for (const route of routes) { + const params = matchRouteSegments(route.segments, pathnameSegments) + if (params) { + return { + route, + params + } + } + } + + return null +} + +export async function invokeRouteModules( + routes: readonly RouteModuleDefinition[], + event: TEvent +): Promise { + const match = matchFetchRoute(routes, event.request) + if (!match) { + return new Response('Not Found', { status: 404 }) + } + + const routeEvent = createFetchEvent(event.request, event.env, event.ctx, { + params: match.params, + locals: event.locals + }) as TEvent + + return runWithEventContext(routeEvent, () => invokeFetchModule(match.route.module, routeEvent)) +} + +export function createRouteResolve( + routes: readonly RouteModuleDefinition[], + initialEvent: TEvent +): ResolveFetch { + return async (nextEvent = initialEvent): Promise => { + return invokeRouteModules(routes, nextEvent) + } +} diff --git a/packages/devflare/src/runtime/router/types.ts b/packages/devflare/src/runtime/router/types.ts new file mode 100644 index 0000000..23bc092 --- /dev/null +++ b/packages/devflare/src/runtime/router/types.ts @@ -0,0 +1,33 @@ +// ============================================================================= +// File Router Types +// ============================================================================= + +export type RouteSegment = + | { + readonly type: 'static' + readonly value: string + } + | { + readonly type: 'param' + readonly name: string + } + | { + readonly type: 'rest' + readonly name: string + } + | { + readonly type: 'optional-rest' + readonly name: string + } + +export interface RouteModuleDefinition { + readonly filePath: string + readonly routePath: string + readonly segments: readonly RouteSegment[] + readonly module: Record +} + +export interface RouteMatchResult { + readonly route: RouteModuleDefinition + readonly params: Record +} diff --git a/packages/devflare/src/runtime/validation.ts b/packages/devflare/src/runtime/validation.ts new file mode 100644 index 0000000..7dbef80 --- /dev/null +++ b/packages/devflare/src/runtime/validation.ts @@ -0,0 +1,141 @@ +// ============================================================================= +// Validation Proxy โ€” Type-safe runtime validation for context access +// ============================================================================= +// Creates proxies that provide helpful error messages when accessed outside +// of an active Devflare-managed handler trail, preventing cryptic +// "cannot read property of undefined" +// ============================================================================= + +/** + * Error thrown when accessing context properties outside an active handler trail + */ +export class ContextAccessError extends Error { + public readonly contextName: string + public readonly propertyName: string + + constructor(contextName: string, propertyName: string) { + super( + `Cannot access ${contextName}.${propertyName} outside of an active Devflare handler trail.\n\n` + + `This typically happens when:\n` + + ` 1. Accessing ${contextName} at module top-level (during import)\n` + + ` 2. Accessing ${contextName} in a callback that runs after the handler ends\n` + + ` 3. Accessing ${contextName} in a setTimeout/setInterval callback\n\n` + + `Move the access inside your handler function or middleware.` + ) + this.name = 'ContextAccessError' + this.contextName = contextName + this.propertyName = propertyName + } +} + +/** + * Creates a proxy that validates context is available before access + * + * @param getter - Function that returns the actual context object (or undefined if unavailable) + * @param name - Name of the context (for error messages) + * @returns Proxy that throws ContextAccessError when accessed outside context + * + * @example + * ```ts + * import { getContextOrNull } from './context' + * + * export const env = createContextProxy( + * () => getContextOrNull()?.env, + * 'env' + * ) + * + * // In handler: works fine + * export default { + * fetch(request, env) { + * console.log(env.DB) // โœ… Works + * } + * } + * + * // At top level: throws helpful error + * const db = env.DB // โŒ ContextAccessError with guidance + * ``` + */ +export interface CreateContextProxyOptions { + /** + * When `false`, the proxy throws a `TypeError` on `set` / `deleteProperty` + * (read-only semantics) and reports descriptors as non-writable. Defaults + * to `true` (mutable; mutations forwarded to the underlying object). + */ + mutable?: boolean +} + +export function createContextProxy( + getter: () => T | null | undefined, + name: string, + options: CreateContextProxyOptions = {} +): T { + const mutable = options.mutable ?? true + return new Proxy({} as T, { + get(_target, prop) { + const ctx = getter() + if (ctx === undefined || ctx === null) { + throw new ContextAccessError(name, String(prop)) + } + return ctx[prop as keyof T] + }, + + set(_target, prop, value) { + if (!mutable) { + throw new TypeError( + `Cannot assign to '${String(prop)}' on '${name}' because it is read-only.\n` + + `Use 'locals' for mutable request-scoped data.` + ) + } + const ctx = getter() + if (ctx === undefined || ctx === null) { + throw new ContextAccessError(name, String(prop)) + } + ; (ctx as Record)[prop] = value + return true + }, + + deleteProperty(_target, prop) { + if (!mutable) { + throw new TypeError( + `Cannot delete property '${String(prop)}' from '${name}' because it is read-only.` + ) + } + const ctx = getter() + if (ctx === undefined || ctx === null) { + return true + } + return Reflect.deleteProperty(ctx, prop) + }, + + has(_target, prop) { + const ctx = getter() + if (ctx === undefined || ctx === null) { + return false + } + return prop in ctx + }, + + ownKeys(_target) { + const ctx = getter() + if (ctx === undefined || ctx === null) { + return [] + } + return Reflect.ownKeys(ctx) + }, + + getOwnPropertyDescriptor(_target, prop) { + const ctx = getter() + if (ctx === undefined || ctx === null) { + return undefined + } + const descriptor = Reflect.getOwnPropertyDescriptor(ctx, prop) + if (!descriptor) { + return undefined + } + if (!mutable) { + return { ...descriptor, writable: false } + } + return descriptor + } + }) +} diff --git a/packages/devflare/src/secrets/local-secrets.ts b/packages/devflare/src/secrets/local-secrets.ts new file mode 100644 index 0000000..a37ed31 --- /dev/null +++ b/packages/devflare/src/secrets/local-secrets.ts @@ -0,0 +1,283 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { normalizeSecretsStoreBinding, type DevflareConfig } from '../config' + +export const LOCAL_SECRETS_PATH = join('.devflare', 'secrets.local.json') + +interface StoredLocalSecret { + value: string + updatedAt: string +} + +interface LocalSecretsFile { + version: 1 + stores: Record> +} + +export interface LocalSecretReference { + cwd: string + storeId: string + name: string +} + +export interface LocalSecretWrite extends LocalSecretReference { + value: string +} + +export interface LocalSecretListOptions { + cwd: string + storeId?: string +} + +export interface LocalSecretListItem { + storeId: string + name: string + hasValue: boolean + updatedAt: string +} + +interface SecretsStoreSecretAdmin { + create(value: string): Promise + update?(value: string, id: string): Promise + list?(): Promise> +} + +interface MiniflareSecretsStoreSeeder { + getSecretsStoreSecretAPI( + bindingName: string, + workerName?: string + ): Promise SecretsStoreSecretAdmin)> +} + +export interface LocalSecretWrappedBindingConfig { + localBindingNames: string[] + wrappedBindings: Record + workers: Array<{ name: string; modules: true; script: string }> +} + +export interface LocalSecretsStoreSecretBinding { + get(): Promise +} + +const LOCAL_SECRET_WRAPPED_BINDING_SCRIPT = ` +class LocalSecretsStoreSecret { + constructor(env) { + this.value = env.value + } + + async get() { + return this.value + } +} + +export default function makeBinding(env) { + return new LocalSecretsStoreSecret(env) +} +` + +function createEmptyLocalSecretsFile(): LocalSecretsFile { + return { + version: 1, + stores: {} + } +} + +function getLocalSecretsFilePath(cwd: string): string { + return join(cwd, LOCAL_SECRETS_PATH) +} + +function parseLocalSecretsFile(raw: string): LocalSecretsFile { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== 1 || !parsed.stores || typeof parsed.stores !== 'object') { + return createEmptyLocalSecretsFile() + } + + return { + version: 1, + stores: parsed.stores + } +} + +function readLocalSecretsFile(cwd: string): LocalSecretsFile { + const filePath = getLocalSecretsFilePath(cwd) + if (!existsSync(filePath)) { + return createEmptyLocalSecretsFile() + } + + return parseLocalSecretsFile(readFileSync(filePath, 'utf8')) +} + +function writeLocalSecretsFile(cwd: string, file: LocalSecretsFile): void { + const filePath = getLocalSecretsFilePath(cwd) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, `${JSON.stringify(file, null, '\t')}\n`, { + encoding: 'utf8', + mode: 0o600 + }) +} + +export function writeLocalSecret({ cwd, storeId, name, value }: LocalSecretWrite): void { + const file = readLocalSecretsFile(cwd) + file.stores[storeId] ??= {} + file.stores[storeId][name] = { + value, + updatedAt: new Date().toISOString() + } + writeLocalSecretsFile(cwd, file) +} + +export function readLocalSecret({ cwd, storeId, name }: LocalSecretReference): string | undefined { + return readLocalSecretsFile(cwd).stores[storeId]?.[name]?.value +} + +export function deleteLocalSecret({ cwd, storeId, name }: LocalSecretReference): boolean { + const file = readLocalSecretsFile(cwd) + const store = file.stores[storeId] + if (!store || !(name in store)) { + return false + } + + delete store[name] + if (Object.keys(store).length === 0) { + delete file.stores[storeId] + } + writeLocalSecretsFile(cwd, file) + return true +} + +export function listLocalSecrets({ cwd, storeId }: LocalSecretListOptions): LocalSecretListItem[] { + const file = readLocalSecretsFile(cwd) + const stores = storeId ? { [storeId]: file.stores[storeId] ?? {} } : file.stores + + return Object.entries(stores).flatMap(([currentStoreId, secrets]) => + Object.entries(secrets).map(([name, secret]) => ({ + storeId: currentStoreId, + name, + hasValue: typeof secret.value === 'string', + updatedAt: secret.updatedAt + })) + ) +} + +export function resolveLocalSecretValuesForBindings( + config: Pick, + cwd: string +): Record { + const values: Record = {} + + for (const [bindingName, binding] of Object.entries(config.bindings?.secretsStore ?? {})) { + const normalized = normalizeSecretsStoreBinding(binding, config.secretsStoreId, bindingName) + const value = readLocalSecret({ + cwd, + storeId: normalized.storeId, + name: normalized.secretName + }) + + if (value !== undefined) { + values[bindingName] = value + } + } + + return values +} + +function toLocalSecretWorkerName(bindingName: string, index: number): string { + const slug = bindingName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'secret' + + return `devflare-local-secret-${index}-${slug}` +} + +export function buildLocalSecretWrappedBindingConfig( + config: Pick, + cwd: string +): LocalSecretWrappedBindingConfig { + const values = resolveLocalSecretValuesForBindings(config, cwd) + const entries = Object.entries(values) + + return { + localBindingNames: entries.map(([bindingName]) => bindingName), + wrappedBindings: Object.fromEntries( + entries.map(([bindingName, value], index) => { + const scriptName = toLocalSecretWorkerName(bindingName, index) + return [ + bindingName, + { + scriptName, + bindings: { value } + } + ] + }) + ), + workers: entries.map(([bindingName], index) => ({ + name: toLocalSecretWorkerName(bindingName, index), + modules: true, + script: LOCAL_SECRET_WRAPPED_BINDING_SCRIPT + })) + } +} + +export function buildLocalSecretNodeBindings( + config: Pick, + cwd: string +): Record { + const values = resolveLocalSecretValuesForBindings(config, cwd) + + return Object.fromEntries( + Object.entries(values).map(([bindingName, value]) => [ + bindingName, + { + async get() { + return value + } + } + ]) + ) +} + +function hasSecretsStoreAdminApi(value: unknown): value is MiniflareSecretsStoreSeeder { + return typeof (value as { getSecretsStoreSecretAPI?: unknown }).getSecretsStoreSecretAPI === 'function' +} + +async function getSecretAdmin( + miniflare: MiniflareSecretsStoreSeeder, + bindingName: string +): Promise { + const adminOrFactory = await miniflare.getSecretsStoreSecretAPI(bindingName) + return typeof adminOrFactory === 'function' ? adminOrFactory() : adminOrFactory +} + +async function upsertMiniflareSecret( + admin: SecretsStoreSecretAdmin, + value: string +): Promise { + if (admin.list && admin.update) { + const [existing] = await admin.list() + const id = existing?.metadata?.uuid ?? existing?.name + if (id) { + await admin.update(value, id) + return + } + } + + await admin.create(value) +} + +export async function seedMiniflareLocalSecrets( + miniflare: unknown, + config: Pick, + cwd: string +): Promise { + if (!hasSecretsStoreAdminApi(miniflare)) { + return + } + + const values = resolveLocalSecretValuesForBindings(config, cwd) + + for (const [bindingName, value] of Object.entries(values)) { + const admin = await getSecretAdmin(miniflare, bindingName) + await upsertMiniflareSecret(admin, value) + } +} diff --git a/packages/devflare/src/shims/local-media-bindings.ts b/packages/devflare/src/shims/local-media-bindings.ts new file mode 100644 index 0000000..14d4212 --- /dev/null +++ b/packages/devflare/src/shims/local-media-bindings.ts @@ -0,0 +1,407 @@ +import type { DevflareConfig } from '../config' + +type LocalMediaShimKind = 'images' | 'media' + +function emptyNodeStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + } + }) +} + +function normalizeNodeContentType(value: string | undefined, fallback: string): string { + if (!value) return fallback + if (value.includes('/')) return value + if (value === 'jpg' || value === 'jpeg') return 'image/jpeg' + if (value === 'png') return 'image/png' + if (value === 'webp') return 'image/webp' + if (value === 'avif') return 'image/avif' + if (value === 'mp4') return 'video/mp4' + return value +} + +async function readNodeBytes(stream: ReadableStream | null | undefined): Promise { + const buffer = await new Response(stream ?? emptyNodeStream()).arrayBuffer() + return new Uint8Array(buffer) +} + +function streamFromNodeBytes(bytes: Uint8Array): ReadableStream { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return new Response(copy).body ?? emptyNodeStream() +} + +function createUnsupportedHostedImagesBinding(): HostedImagesBinding { + const unsupported = () => { + throw new Error( + 'Devflare local Images hosted API is not implemented. Use transform/info APIs locally, or connect to Cloudflare for hosted image storage.' + ) + } + + return { + image(_imageId: string): ImageHandle { + return { + details: unsupported, + bytes: unsupported, + update: unsupported, + delete: unsupported + } as ImageHandle + }, + upload: unsupported, + list: unsupported + } as HostedImagesBinding +} + +function createNodeImageResult(bytes: Uint8Array, contentType: string): ImageTransformationResult { + return { + response(): Response { + return new Response(streamFromNodeBytes(bytes), { + headers: { 'Content-Type': contentType } + }) + }, + contentType(): string { + return contentType + }, + image(): ReadableStream { + return streamFromNodeBytes(bytes) + } + } as ImageTransformationResult +} + +function createNodeImageTransformer(bytesPromise: Promise): ImageTransformer { + const transformer: ImageTransformer = { + transform(_transform: ImageTransform): ImageTransformer { + return transformer + }, + draw( + _image: ReadableStream | ImageTransformer, + _options?: ImageDrawOptions + ): ImageTransformer { + return transformer + }, + async output(options: ImageOutputOptions): Promise { + return createNodeImageResult( + await bytesPromise, + normalizeNodeContentType(options?.format, 'image/png') + ) + } + } + + return transformer +} + +export function createLocalImagesBinding(): ImagesBinding { + return { + async info(stream: ReadableStream): Promise { + const bytes = await readNodeBytes(stream) + return { + format: 'image/png', + fileSize: bytes.byteLength, + width: 1, + height: 1 + } + }, + input(stream: ReadableStream): ImageTransformer { + return createNodeImageTransformer(readNodeBytes(stream)) + }, + hosted: createUnsupportedHostedImagesBinding() + } as ImagesBinding +} + +function createNodeMediaResult( + bytesPromise: Promise, + contentType: string +): MediaTransformationResult { + return { + async media(): Promise> { + return streamFromNodeBytes(await bytesPromise) + }, + async response(): Promise { + return new Response(streamFromNodeBytes(await bytesPromise), { + headers: { 'Content-Type': contentType } + }) + }, + async contentType(): Promise { + return contentType + } + } as MediaTransformationResult +} + +function createNodeMediaTransformer(bytesPromise: Promise): MediaTransformer { + const output = (options: MediaTransformationOutputOptions = {}) => + createNodeMediaResult( + bytesPromise, + normalizeNodeContentType(options.format, 'video/mp4') + ) + + return { + transform(_transform?: MediaTransformationInputOptions): MediaTransformationGenerator { + return { + output(options?: MediaTransformationOutputOptions): MediaTransformationResult { + return output(options) + } + } + }, + output(options?: MediaTransformationOutputOptions): MediaTransformationResult { + return output(options) + } + } as MediaTransformer +} + +export function createLocalMediaBinding(): MediaBinding { + return { + input(stream: ReadableStream): MediaTransformer { + return createNodeMediaTransformer(readNodeBytes(stream)) + } + } as MediaBinding +} + +export interface LocalBindingShimServiceConfig { + localBindingNames: string[] + serviceBindings: Record + workers: Array<{ + name: string + modules: true + script: string + compatibilityDate: string + compatibilityFlags?: string[] + }> +} + +const LOCAL_MEDIA_BINDING_SCRIPT = ` +import { RpcTarget, WorkerEntrypoint } from 'cloudflare:workers' + +function emptyStream() { + return new ReadableStream({ + start(controller) { + controller.close() + } + }) +} + +function normalizeContentType(value, fallback) { + if (typeof value !== 'string' || value.length === 0) return fallback + if (value.includes('/')) return value + if (value === 'jpg') return 'image/jpeg' + if (value === 'jpeg') return 'image/jpeg' + if (value === 'png') return 'image/png' + if (value === 'webp') return 'image/webp' + if (value === 'avif') return 'image/avif' + if (value === 'mp4') return 'video/mp4' + return value +} + +async function readBytes(stream) { + return await new Response(stream || emptyStream()).arrayBuffer() +} + +function streamFromBytes(bytes) { + return new Response(bytes.slice(0)).body || emptyStream() +} + +function createBodyReader(bytes) { + return function takeBody() { + return streamFromBytes(bytes) + } +} + +class LocalImageResult extends RpcTarget { + constructor(takeBody, contentType) { + super() + this.takeBody = takeBody + this.contentTypeValue = contentType + } + + response() { + return new Response(this.takeBody(), { + headers: { 'Content-Type': this.contentTypeValue } + }) + } + + contentType() { + return this.contentTypeValue + } + + image() { + return this.takeBody() + } +} + +class LocalImageTransformer extends RpcTarget { + constructor(bytes) { + super() + this.takeBody = createBodyReader(bytes) + } + + transform() { + return this + } + + draw() { + return this + } + + async output(options = {}) { + return new LocalImageResult( + this.takeBody, + normalizeContentType(options.format, 'image/png') + ) + } +} + +function createHostedImagesBinding() { + const unsupported = () => { + throw new Error('Devflare local Images hosted API is not implemented. Use the transform/info APIs locally, or connect to Cloudflare for hosted image storage.') + } + return { + image() { + return { + details: unsupported, + bytes: unsupported, + update: unsupported, + delete: unsupported + } + }, + upload: unsupported, + list: unsupported + } +} + +export class LocalImagesBinding extends WorkerEntrypoint { + async info(stream) { + const bytes = await readBytes(stream) + return { + format: 'image/png', + fileSize: bytes.byteLength, + width: 1, + height: 1 + } + } + + async input(stream) { + return new LocalImageTransformer(await readBytes(stream)) + } + + get hosted() { + return createHostedImagesBinding() + } +} + +class LocalMediaResult extends RpcTarget { + constructor(takeBody, contentType) { + super() + this.takeBody = takeBody + this.contentTypeValue = contentType + } + + async media() { + return this.takeBody() + } + + async response() { + return new Response(this.takeBody(), { + headers: { 'Content-Type': this.contentTypeValue } + }) + } + + async contentType() { + return this.contentTypeValue + } +} + +class LocalMediaTransformationGenerator extends RpcTarget { + constructor(takeBody) { + super() + this.takeBody = takeBody + } + + output(options = {}) { + return new LocalMediaResult( + this.takeBody, + normalizeContentType(options.format, 'video/mp4') + ) + } +} + +class LocalMediaTransformer extends RpcTarget { + constructor(bytes) { + super() + this.takeBody = createBodyReader(bytes) + } + + transform() { + return new LocalMediaTransformationGenerator(this.takeBody) + } + + output(options = {}) { + return new LocalMediaResult( + this.takeBody, + normalizeContentType(options.format, 'video/mp4') + ) + } +} + +export class LocalMediaBinding extends WorkerEntrypoint { + async input(stream) { + return new LocalMediaTransformer(await readBytes(stream)) + } +} + +export default { + fetch() { + return new Response('Devflare local Images/Media binding') + } +} +` + +function toLocalShimWorkerName(kind: LocalMediaShimKind, bindingName: string, index: number): string { + const slug = bindingName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || kind + + return `devflare-local-${kind}-${index}-${slug}` +} + +function getEntrypoint(kind: LocalMediaShimKind): string { + return kind === 'images' ? 'LocalImagesBinding' : 'LocalMediaBinding' +} + +export function buildLocalBindingShimServiceConfig( + config: Pick +): LocalBindingShimServiceConfig { + const entries: Array<{ bindingName: string; kind: LocalMediaShimKind }> = [ + ...Object.keys(config.bindings?.images ?? {}).map((bindingName) => ({ + bindingName, + kind: 'images' as const + })), + ...Object.keys(config.bindings?.media ?? {}).map((bindingName) => ({ + bindingName, + kind: 'media' as const + })) + ] + + return { + localBindingNames: entries.map((entry) => entry.bindingName), + serviceBindings: Object.fromEntries( + entries.map((entry, index) => { + const workerName = toLocalShimWorkerName(entry.kind, entry.bindingName, index) + return [ + entry.bindingName, + { + name: workerName, + entrypoint: getEntrypoint(entry.kind) + } + ] + }) + ), + workers: entries.map((entry, index) => ({ + name: toLocalShimWorkerName(entry.kind, entry.bindingName, index), + modules: true, + script: LOCAL_MEDIA_BINDING_SCRIPT, + compatibilityDate: config.compatibilityDate ?? '2025-01-01', + ...(config.compatibilityFlags && { compatibilityFlags: config.compatibilityFlags }) + })) + } +} diff --git a/packages/devflare/src/shims/local-worker-loader.ts b/packages/devflare/src/shims/local-worker-loader.ts new file mode 100644 index 0000000..fb10962 --- /dev/null +++ b/packages/devflare/src/shims/local-worker-loader.ts @@ -0,0 +1,93 @@ +const activeWorkerLoaderRuntimes = new Set<{ dispose(): Promise }>() + +type WorkerLoaderCodeModule = WorkerLoaderWorkerCode['modules'][string] + +function getModuleSource(module: WorkerLoaderCodeModule | undefined, mainModule: string): string { + if (typeof module === 'string') { + return module + } + + if (module && typeof module === 'object' && typeof module.js === 'string') { + return module.js + } + + throw new Error( + `Worker Loader local shim only supports JavaScript main modules. Could not load "${mainModule}".` + ) +} + +function createRequestInit(request: Request): RequestInit { + return { + method: request.method, + headers: request.headers, + body: request.body + } +} + +async function createRuntime(code: WorkerLoaderWorkerCode): Promise { + const { Miniflare } = await import('miniflare') + const mainModule = code.mainModule + const script = getModuleSource(code.modules[mainModule], mainModule) + const miniflare = new Miniflare({ + modules: true, + compatibilityDate: code.compatibilityDate, + ...(code.compatibilityFlags && { compatibilityFlags: code.compatibilityFlags }), + ...(code.env && { bindings: code.env }), + script + }) + + await miniflare.ready + activeWorkerLoaderRuntimes.add(miniflare) + return miniflare +} + +function createLocalWorkerStub( + codeProvider: () => WorkerLoaderWorkerCode | Promise +): WorkerStub { + let runtimePromise: Promise | null = null + + const getRuntime = () => { + runtimePromise ??= Promise.resolve(codeProvider()).then(createRuntime) + return runtimePromise + } + + const fetcher = { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = input instanceof Request ? input : new Request(input, init) + const runtime = await getRuntime() + return runtime.dispatchFetch(request.url, createRequestInit(request)) + } + } + + return { + getEntrypoint() { + return fetcher + }, + getDurableObjectClass() { + throw new Error('Worker Loader local shim does not support dynamic Durable Object classes yet.') + } + } as unknown as WorkerStub +} + +export function createLocalWorkerLoaderBinding(): WorkerLoader { + return { + get( + _name: string | null, + getCode: () => WorkerLoaderWorkerCode | Promise + ): WorkerStub { + return createLocalWorkerStub(getCode) + }, + load(code: WorkerLoaderWorkerCode): WorkerStub { + return createLocalWorkerStub(() => code) + } + } +} + +export async function disposeLocalWorkerLoaderBindings(): Promise { + const runtimes = Array.from(activeWorkerLoaderRuntimes) + activeWorkerLoaderRuntimes.clear() + + await Promise.all(runtimes.map(async (runtime) => { + await runtime.dispose() + })) +} diff --git a/packages/devflare/src/sveltekit/index.ts b/packages/devflare/src/sveltekit/index.ts new file mode 100644 index 0000000..522e917 --- /dev/null +++ b/packages/devflare/src/sveltekit/index.ts @@ -0,0 +1,25 @@ +// ============================================================================= +// SvelteKit Integration Module +// ============================================================================= +// Provides utilities for integrating devflare with SvelteKit +// ============================================================================= + +export { + // Pre-configured handle โ€” just re-export for simplest usage + handle, + + // Factory for custom configuration + createDevflarePlatform, + createHandle, + + // Utilities + resetPlatform, + resetConfigCache, + isDevflareDev, + getBridgePort, + + // Types + type Platform, + type DevflarePlatformOptions, + type CreateHandleOptions +} from './platform' diff --git a/packages/devflare/src/sveltekit/local-bindings.ts b/packages/devflare/src/sveltekit/local-bindings.ts new file mode 100644 index 0000000..c4163fe --- /dev/null +++ b/packages/devflare/src/sveltekit/local-bindings.ts @@ -0,0 +1,120 @@ +import { + normalizeHyperdriveBinding, + type DevflareConfig +} from '../config' +import { buildLocalSecretNodeBindings } from '../secrets/local-secrets' +import { + createLocalImagesBinding, + createLocalMediaBinding +} from '../shims/local-media-bindings' +import { createLocalWorkerLoaderBinding } from '../shims/local-worker-loader' +import { createLocalSendEmailBinding } from '../utils/send-email' + +function defaultPortForDatabaseUrl(url: URL): number { + if (url.port) { + return Number(url.port) + } + + return url.protocol === 'mysql:' ? 3306 : 5432 +} + +function createLocalHyperdriveBinding(connectionString: string): Hyperdrive { + const url = new URL(connectionString) + + return { + connectionString, + host: url.hostname, + port: defaultPortForDatabaseUrl(url), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: decodeURIComponent(url.pathname.replace(/^\//, '')), + connect(): Socket { + throw new Error( + 'Devflare local Hyperdrive exposes connectionString for local database clients. Raw socket connect() is not implemented in the SvelteKit Node adapter path.' + ) + } + } as Hyperdrive +} + +function buildLocalHyperdriveBindings(config: DevflareConfig): Record { + const bindings: Record = {} + + for (const [name, binding] of Object.entries(config.bindings?.hyperdrive ?? {})) { + const normalized = normalizeHyperdriveBinding(binding) + if (normalized.localConnectionString) { + bindings[name] = createLocalHyperdriveBinding(normalized.localConnectionString) + } + } + + return bindings +} + +export function buildSvelteKitLocalBindings( + config: DevflareConfig, + cwd: string +): Record { + const bindings: Record = { + ...(config.vars ?? {}), + ...buildLocalHyperdriveBindings(config), + ...buildLocalSecretNodeBindings(config, cwd) + } + + for (const [name, binding] of Object.entries(config.bindings?.sendEmail ?? {})) { + bindings[name] = createLocalSendEmailBinding(binding) + } + + for (const name of Object.keys(config.bindings?.workerLoaders ?? {})) { + bindings[name] = createLocalWorkerLoaderBinding() + } + + for (const name of Object.keys(config.bindings?.images ?? {})) { + bindings[name] = createLocalImagesBinding() + } + + for (const name of Object.keys(config.bindings?.media ?? {})) { + bindings[name] = createLocalMediaBinding() + } + + return bindings +} + +export function overlayLocalBindings( + baseEnv: Record, + localBindings: Record +): Record { + if (Object.keys(localBindings).length === 0) { + return baseEnv + } + + return new Proxy(baseEnv, { + get(target, prop, receiver) { + if (typeof prop === 'string' && prop in localBindings) { + return localBindings[prop] + } + + return Reflect.get(target, prop, receiver) + }, + has(target, prop) { + return (typeof prop === 'string' && prop in localBindings) + || Reflect.has(target, prop) + }, + ownKeys(target) { + return Array.from(new Set([ + ...Reflect.ownKeys(target), + ...Reflect.ownKeys(localBindings) + ])) + }, + getOwnPropertyDescriptor(target, prop) { + if (typeof prop === 'string' && prop in localBindings) { + return { + configurable: true, + enumerable: true, + writable: false, + value: localBindings[prop] + } + } + + return Reflect.getOwnPropertyDescriptor(target, prop) + } + }) +} diff --git a/packages/devflare/src/sveltekit/platform.ts b/packages/devflare/src/sveltekit/platform.ts new file mode 100644 index 0000000..6fe6adf --- /dev/null +++ b/packages/devflare/src/sveltekit/platform.ts @@ -0,0 +1,466 @@ +// ============================================================================= +// SvelteKit Platform Integration +// ============================================================================= +// Provides a `platform` object that uses the bridge to communicate with Miniflare +// in development mode, while passing through the real platform in production. +// ============================================================================= + +import { loadConfig, type DevflareConfig } from '../config' +import { createEnvProxy, getClient, setBindingHints, type BindingHints } from '../bridge' +import { extractBindingHints } from '../test/binding-hints' +import { createFetchEvent, runWithEventContext } from '../runtime/context' +import { buildSvelteKitLocalBindings, overlayLocalBindings } from './local-bindings' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * SvelteKit platform object shape + */ +export interface Platform { + env: Record + context: ExecutionContext + caches: CacheStorage + cf: Record + /** + * Errors captured from `ctx.waitUntil()` rejections in dev mode. + * Drain via `drainWaitUntilErrors(platform)`. + */ + pendingErrors?: unknown[] +} + +export interface DevflarePlatformOptions { + /** + * WebSocket URL for the bridge connection + * @default 'ws://localhost:8787' (uses Miniflare port) + */ + bridgeUrl?: string + + /** + * Binding type hints for better proxy creation + * Keys are binding names, values are binding types + */ + hints?: BindingHints + + /** + * Local Node-side binding shims to prefer over the bridge-backed env. + * Used by the SvelteKit handle for bindings whose local API exposes + * synchronous properties or rich transformation objects. + */ + localBindings?: Record +} + +// ----------------------------------------------------------------------------- +// Platform Proxy +// ----------------------------------------------------------------------------- + +/** Cached platform keyed by bridgeUrl + binding hint fingerprint */ +let platformCache: { key: string; platform: Platform } | null = null + +/** + * Generate a stable fingerprint for binding hints so cached platforms are not + * shared across configs with differing hint sets. + */ +function fingerprintHints(hints: BindingHints): string { + const entries = Object.keys(hints).sort().map((name) => [name, hints[name]]) + return JSON.stringify(entries) +} + +/** + * Generate cache key from options + */ +function getPlatformCacheKey(bridgeUrl: string, hints: BindingHints): string { + return `${bridgeUrl}\u0000${fingerprintHints(hints)}` +} + +function shouldUseCachedPlatform(localBindings: Record): boolean { + return Object.keys(localBindings).length === 0 +} + +/** + * Build a dev-mode ExecutionContext that records `waitUntil()` rejections on + * the provided `pendingErrors` array. The original console.error log is kept + * for parity with existing behavior. + */ +function createDevExecutionContext(pendingErrors: unknown[]): ExecutionContext { + return { + waitUntil: (promise: Promise) => { + promise.catch((err) => { + console.error('[devflare] waitUntil error:', err) + pendingErrors.push(err) + }) + }, + passThroughOnException: () => { + // No-op in dev mode + } + } as ExecutionContext +} + +/** + * Drain errors captured from `ctx.waitUntil()` calls on a dev platform. + * Returns a snapshot of the pending errors and clears the buffer. + */ +export function drainWaitUntilErrors(platform: Platform): unknown[] { + const buffer = platform.pendingErrors + if (!buffer || buffer.length === 0) { + return [] + } + const drained = buffer.slice() + buffer.length = 0 + return drained +} + +/** + * Create a platform object that routes bindings through the bridge + * + * Use this in dev mode to get access to Miniflare bindings via WebSocket RPC. + * + * @example + * ```ts + * // src/hooks.server.ts + * import { dev } from '$app/environment' + * import { createDevflarePlatform } from 'devflare/sveltekit' + * + * export async function handle({ event, resolve }) { + * if (dev && process.env.DEVFLARE_DEV) { + * // Override platform with bridge-connected proxy + * event.platform = await createDevflarePlatform({ + * hints: { + * MY_KV: 'kv', + * MY_DO: 'do', + * MY_D1: 'd1', + * MY_R2: 'r2' + * } + * }) + * } + * return resolve(event) + * } + * ``` + */ +export async function createDevflarePlatform( + options: DevflarePlatformOptions = {} +): Promise { + const { + bridgeUrl = `ws://localhost:${process.env.DEVFLARE_BRIDGE_PORT ?? 8787}`, + hints = {}, + localBindings = {} + } = options + + const cacheKey = getPlatformCacheKey(bridgeUrl, hints) + + // Return cached platform if exists for this bridgeUrl + hint fingerprint + if (shouldUseCachedPlatform(localBindings) && platformCache?.key === cacheKey) { + return platformCache.platform + } + + // Get/create bridge client + const client = getClient({ url: bridgeUrl }) + + // Connect to bridge + await client.connect() + + // Create env proxy with hints + const env = overlayLocalBindings(createEnvProxy({ client, hints, strict: true }), localBindings) + + // Create mock execution context that captures waitUntil rejections + const pendingErrors: unknown[] = [] + const context = createDevExecutionContext(pendingErrors) + + // Create mock caches + const caches = { + default: createMockCache(), + open: async (cacheName: string) => createMockCache() + } as unknown as CacheStorage + + // Create mock cf object + const cf: Record = { + colo: 'DEV', + country: 'XX', + city: 'Development', + continent: 'XX', + latitude: '0', + longitude: '0', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + region: 'Development', + regionCode: 'DEV', + asn: 0, + asOrganization: 'Devflare Dev' + } + + const platform: Platform = { env, context, caches, cf, pendingErrors } + if (shouldUseCachedPlatform(localBindings)) { + platformCache = { key: cacheKey, platform } + } + return platform +} + +/** + * Create a simple mock cache for dev mode + * Note: Cloudflare's Cache interface differs from browser Cache API + */ +function createMockCache() { + const store = new Map() + + return { + async match(request: RequestInfo | URL): Promise { + const key = typeof request === 'string' ? request : request instanceof URL ? request.href : request.url + return store.get(key)?.clone() + }, + async put(request: RequestInfo | URL, response: Response): Promise { + const key = typeof request === 'string' ? request : request instanceof URL ? request.href : request.url + store.set(key, response.clone()) + }, + async delete(request: RequestInfo | URL): Promise { + const key = typeof request === 'string' ? request : request instanceof URL ? request.href : request.url + return store.delete(key) + } + } as unknown as Cache +} + +/** + * Reset the cached platform (for testing) + */ +export function resetPlatform(): void { + platformCache = null +} + +/** + * Reset the cached config (for testing) + */ +export function resetConfigCache(): void { + configCache = null +} + +/** + * Check if running in devflare dev mode + */ +export function isDevflareDev(): boolean { + return process.env.DEVFLARE_DEV === 'true' +} + +/** + * Get the bridge port from environment + */ +export function getBridgePort(): number { + return parseInt(process.env.DEVFLARE_BRIDGE_PORT ?? '8787', 10) +} + +// ----------------------------------------------------------------------------- +// Auto-discover Hints from Config +// ----------------------------------------------------------------------------- + +/** Cached config promise keyed by cwd + explicit config path */ +let configCache: { + cwd: string + configFile: string | undefined + promise: Promise +} | null = null + +function getConfigFileFromEnv(): string | undefined { + return process.env.DEVFLARE_CONFIG_PATH +} + +async function loadConfigFromCurrentCwd(): Promise { + const cwd = process.cwd() + const configFile = getConfigFileFromEnv() + + // Check if we have a cached promise for this cwd + if (configCache?.cwd === cwd && configCache.configFile === configFile) { + return configCache.promise + } + + // Create new cache entry with promise (handles concurrent requests) + const promise = loadConfig({ cwd, configFile }).catch((err) => { + // Log error in debug mode + if (process.env.DEVFLARE_DEBUG) { + console.warn('[devflare] Failed to load config for hints:', err.message) + } + return null + }) + + configCache = { cwd, configFile, promise } + + return promise +} + +async function loadPlatformOptionsFromConfig(): Promise> { + const cwd = process.cwd() + const config = await loadConfigFromCurrentCwd() + if (!config) { + return { hints: {}, localBindings: {} } + } + + return { + hints: extractBindingHints(config), + localBindings: buildSvelteKitLocalBindings(config, cwd) + } +} + +function resolveWithPlatformContext< + TEvent extends { platform?: unknown; request?: Request }, + TResolve extends (event: unknown) => Response | Promise +>( + event: TEvent, + resolve: TResolve, + platform: Platform +): Response | Promise { + if (!(event.request instanceof Request)) { + return resolve(event) + } + + const fetchEvent = createFetchEvent(event.request, platform.env, platform.context) + return runWithEventContext(fetchEvent, () => resolve(event)) +} + +async function createPlatformWithRequestContext< + TEvent extends { platform?: unknown; request?: Request }, + TResolve extends (event: unknown) => Response | Promise +>( + event: TEvent, + resolve: TResolve, + options: DevflarePlatformOptions +): Promise { + const platform = await createDevflarePlatform(options) + event.platform = platform as typeof event.platform + return resolveWithPlatformContext(event, resolve, platform) +} + +async function getAutoPlatformOptions(): Promise { + const options = await loadPlatformOptionsFromConfig() + setBindingHints(options.hints ?? {}) + return options +} + +async function getCustomPlatformOptions( + options: DevflarePlatformOptions +): Promise { + if (options.hints) { + setBindingHints(options.hints) + } + + return options +} + +// ----------------------------------------------------------------------------- +// SvelteKit Handle +// ----------------------------------------------------------------------------- + +/** + * Options for createHandle + */ +export interface CreateHandleOptions extends DevflarePlatformOptions { + /** + * Custom condition to check if devflare should be enabled. + * Defaults to checking `dev && process.env.DEVFLARE_DEV === 'true'` + */ + shouldEnable?: () => boolean +} + +/** + * Create a SvelteKit handle that automatically injects the devflare platform + * in development mode. This eliminates the need for boilerplate in hooks.server.ts. + * + * @example + * ```ts + * // src/hooks.server.ts + * import { createHandle } from 'devflare/sveltekit' + * + * export const handle = createHandle({ + * hints: { + * MY_KV: 'kv', + * MY_DO: 'do', + * MY_D1: 'd1', + * MY_R2: 'r2' + * } + * }) + * ``` + * + * @example Composing with other handles using SvelteKit's sequence + * ```ts + * import { sequence } from '@sveltejs/kit/hooks' + * import { createHandle } from 'devflare/sveltekit' + * + * const devflareHandle = createHandle({ hints: { ... } }) + * const authHandle: Handle = async ({ event, resolve }) => { ... } + * + * export const handle = sequence(devflareHandle, authHandle) + * ``` + */ +export function createHandle Response | Promise }>( + options: CreateHandleOptions = {} +): (input: T) => Promise { + const { shouldEnable, ...platformOptions } = options + + return async ({ event, resolve }) => { + // Check if devflare should be enabled + const enabled = shouldEnable + ? shouldEnable() + : (process.env.NODE_ENV !== 'production' && process.env.DEVFLARE_DEV === 'true') + + if (enabled) { + try { + return await createPlatformWithRequestContext( + event, + resolve, + await getCustomPlatformOptions(platformOptions) + ) + } catch (error) { + console.error('[devflare] Failed to create platform:', error) + // Fall through to default platform + } + } + + return resolve(event) + } +} + +// ----------------------------------------------------------------------------- +// Pre-configured Handle (Auto-loads hints from config) +// ----------------------------------------------------------------------------- + +/** + * Pre-configured SvelteKit handle that auto-loads binding hints from devflare.config.ts. + * + * This is the simplest way to integrate devflare with SvelteKit: + * + * @example Simplest usage โ€” just re-export + * ```ts + * // src/hooks.server.ts + * export { handle } from 'devflare/sveltekit' + * ``` + * + * @example With other handles + * ```ts + * // src/hooks.server.ts + * import { sequence } from '@sveltejs/kit/hooks' + * import { handle as devflareHandle } from 'devflare/sveltekit' + * + * const authHandle: Handle = async ({ event, resolve }) => { ... } + * + * export const handle = sequence(devflareHandle, authHandle) + * ``` + */ +export const handle = async Response | Promise }>( + input: T +): Promise => { + const { event, resolve } = input + + // Check if devflare should be enabled + const enabled = process.env.NODE_ENV !== 'production' && process.env.DEVFLARE_DEV === 'true' + + if (enabled) { + try { + return await createPlatformWithRequestContext( + event, + resolve, + await getAutoPlatformOptions() + ) + } catch (error) { + console.error('[devflare] Failed to create platform:', error) + // Fall through to default platform + } + } + + return resolve(event) +} diff --git a/packages/devflare/src/test/ai-search.ts b/packages/devflare/src/test/ai-search.ts new file mode 100644 index 0000000..eada464 --- /dev/null +++ b/packages/devflare/src/test/ai-search.ts @@ -0,0 +1,714 @@ +// ============================================================================= +// AI Search Test Mocks +// ============================================================================= +// Deterministic, in-memory AI Search bindings for pure unit tests. These mocks +// exercise application control flow without pretending to reproduce Cloudflare's +// hosted indexing, ranking, crawling, or model behavior. +// ============================================================================= + +type AISearchChunk = AiSearchSearchResponse['chunks'][number] +type AISearchMultiChunk = AiSearchMultiSearchResponse['chunks'][number] + +export interface MockAISearchItemFixture { + id?: string + key: string + content?: string + contentType?: string + metadata?: Record + status?: AiSearchItemInfo['status'] + chunks?: string[] +} + +export interface MockAISearchInstanceOptions { + id?: string + namespace?: string + info?: Partial + items?: MockAISearchItemFixture[] + chatMessage?: + | string + | ((chunks: AISearchChunk[], request: AiSearchChatCompletionsRequest) => string) +} + +export interface MockAISearchNamespaceOptions { + namespace?: string + instances?: Record +} + +export type MockAISearchInstance = AiSearchInstance & { + _getSearches(): AiSearchSearchRequest[] + _getItems(): AiSearchItemInfo[] + _getJobs(): AiSearchJobInfo[] +} + +export type MockAISearchNamespace = AiSearchNamespace & { + _getInstances(): string[] +} + +interface StoredAISearchItem { + info: AiSearchItemInfo + content: string + contentType: string + chunks: AiSearchItemChunk[] + logs: AiSearchItemLog[] +} + +const MOCK_TIMESTAMP = '2026-04-26T00:00:00.000Z' + +function cloneInfo>(value: T): T { + return { ...value } +} + +function extractSearchQuery(params: AiSearchSearchRequest | AiSearchMultiSearchRequest): string { + if ('query' in params && typeof params.query === 'string') { + return params.query + } + + const messages = 'messages' in params ? params.messages : [] + return messages + .filter((message) => message.role === 'user' && message.content) + .map((message) => message.content) + .join(' ') +} + +function tokenize(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9_]+/) + .filter((token) => token.length > 2) +} + +function scoreText(query: string, text: string): number { + const tokens = tokenize(query) + if (tokens.length === 0) { + return 1 + } + + const haystack = text.toLowerCase() + const matches = tokens.filter((token) => haystack.includes(token)).length + return matches === 0 ? 0 : Math.max(0.1, matches / tokens.length) +} + +function streamFromText(text: string): ReadableStream { + const encoded = new TextEncoder().encode(text) + return new ReadableStream({ + start(controller) { + controller.enqueue(encoded) + controller.close() + } + }) +} + +async function contentToText(content: ReadableStream | Blob | string): Promise { + if (typeof content === 'string') { + return content + } + if (content instanceof Blob) { + return content.text() + } + return new Response(content).text() +} + +function createItemInfo( + id: string, + key: string, + content: string, + options: { + metadata?: Record + status?: AiSearchItemInfo['status'] + namespace?: string + sourceId?: string + } = {} +): AiSearchItemInfo { + return { + id, + key, + status: options.status ?? 'completed', + next_action: null, + namespace: options.namespace, + chunks_count: content.length > 0 ? 1 : 0, + file_size: new TextEncoder().encode(content).byteLength, + source_id: options.sourceId ?? null, + last_seen_at: MOCK_TIMESTAMP, + created_at: MOCK_TIMESTAMP, + metadata: options.metadata + } +} + +function createItemChunks(item: AiSearchItemInfo, chunks: string[]): AiSearchItemChunk[] { + let offset = 0 + return chunks.map((text, index) => { + const start = offset + const end = start + new TextEncoder().encode(text).byteLength + offset = end + return { + id: `${item.id}:chunk-${index + 1}`, + text, + start_byte: start, + end_byte: end, + item: { + timestamp: Date.parse(MOCK_TIMESTAMP), + key: item.key, + metadata: item.metadata + } + } + }) +} + +function createStoredItem( + sequence: number, + fixture: MockAISearchItemFixture, + namespace?: string +): StoredAISearchItem { + const content = fixture.content ?? fixture.chunks?.join('\n') ?? '' + const id = fixture.id ?? `item-${sequence}` + const info = createItemInfo(id, fixture.key, content, { + metadata: fixture.metadata, + status: fixture.status, + namespace + }) + const chunks = createItemChunks(info, fixture.chunks ?? [content]) + return { + info, + content, + contentType: fixture.contentType ?? 'text/plain', + chunks, + logs: [ + { + timestamp: MOCK_TIMESTAMP, + action: 'index', + message: `Indexed ${fixture.key}`, + fileKey: fixture.key, + chunkCount: chunks.length, + processingTimeMs: 0 + } + ] + } +} + +function isAISearchInstance( + value: MockAISearchInstanceOptions | AiSearchInstance +): value is AiSearchInstance { + return typeof (value as { search?: unknown }).search === 'function' +} + +/** + * Creates a deterministic AI Search instance binding for pure unit tests. + */ +export function createMockAISearchInstance( + options: MockAISearchInstanceOptions = {} +): MockAISearchInstance { + const instanceId = options.id ?? options.info?.id ?? 'mock-ai-search' + const namespace = options.namespace ?? options.info?.namespace + const items = new Map() + const jobs = new Map() + const searches: AiSearchSearchRequest[] = [] + let itemSequence = 0 + let jobSequence = 0 + let info: AiSearchInstanceInfo = { + id: instanceId, + type: 'builtin', + namespace, + status: 'ready', + created_at: MOCK_TIMESTAMP, + modified_at: MOCK_TIMESTAMP, + index_method: { + vector: true, + keyword: true + }, + fusion_method: 'rrf', + ...options.info + } + + for (const fixture of options.items ?? []) { + const stored = createStoredItem(++itemSequence, fixture, namespace) + items.set(stored.info.id, stored) + } + + const findMatches = (params: AiSearchSearchRequest): AiSearchSearchResponse => { + searches.push(params) + const query = extractSearchQuery(params) + const maxResults = params.ai_search_options?.retrieval?.max_num_results ?? 10 + const chunks: AISearchChunk[] = [] + + for (const item of items.values()) { + for (const chunk of item.chunks) { + const score = scoreText(query, `${item.info.key} ${chunk.text}`) + if (score === 0) { + continue + } + chunks.push({ + id: chunk.id, + type: 'text', + score, + text: chunk.text, + item: { + timestamp: Date.parse(MOCK_TIMESTAMP), + key: item.info.key, + metadata: item.info.metadata + }, + scoring_details: { + keyword_score: score, + vector_score: score, + keyword_rank: chunks.length + 1, + vector_rank: chunks.length + 1, + fusion_method: 'rrf' + } + }) + } + } + + return { + search_query: query, + chunks: chunks.slice(0, maxResults) + } + } + + const createItemHandle = (itemId: string): AiSearchItem => + ({ + async info(): Promise { + const item = items.get(itemId) + if (!item) { + throw new Error(`Mock AI Search item "${itemId}" was not found.`) + } + return cloneInfo(item.info) + }, + async download(): Promise { + const item = items.get(itemId) + if (!item) { + throw new Error(`Mock AI Search item "${itemId}" was not found.`) + } + return { + body: streamFromText(item.content), + contentType: item.contentType, + filename: item.info.key, + size: new TextEncoder().encode(item.content).byteLength + } + }, + async sync(): Promise { + const item = items.get(itemId) + if (!item) { + throw new Error(`Mock AI Search item "${itemId}" was not found.`) + } + item.info.status = 'completed' + item.info.last_seen_at = MOCK_TIMESTAMP + return cloneInfo(item.info) + }, + async logs(params?: AiSearchItemLogsParams): Promise { + const item = items.get(itemId) + if (!item) { + throw new Error(`Mock AI Search item "${itemId}" was not found.`) + } + const limit = params?.limit ?? 50 + const result = item.logs.slice(0, limit) + return { + result, + result_info: { + count: result.length, + per_page: limit, + cursor: null, + truncated: item.logs.length > result.length + } + } + }, + async chunks(params?: AiSearchItemChunksParams): Promise { + const item = items.get(itemId) + if (!item) { + throw new Error(`Mock AI Search item "${itemId}" was not found.`) + } + const offset = params?.offset ?? 0 + const limit = params?.limit ?? 20 + const result = item.chunks.slice(offset, offset + limit) + return { + result, + result_info: { + count: result.length, + total: item.chunks.length, + limit, + offset + } + } + } + }) as AiSearchItem + + const uploadItem = async ( + name: string, + content: ReadableStream | Blob | string, + uploadOptions?: AiSearchUploadItemOptions + ): Promise => { + const text = await contentToText(content) + const existing = Array.from(items.values()).find((item) => item.info.key === name) + const stored = createStoredItem( + existing ? Number(existing.info.id.replace(/^item-/, '')) || ++itemSequence : ++itemSequence, + { + id: existing?.info.id, + key: name, + content: text, + metadata: uploadOptions?.metadata + }, + namespace + ) + items.set(stored.info.id, stored) + return cloneInfo(stored.info) + } + + const itemsApi: AiSearchItems = { + async list(params?: AiSearchListItemsParams): Promise { + let result = Array.from(items.values()).map((item) => cloneInfo(item.info)) + if (params?.search) { + const search = params.search.toLowerCase() + result = result.filter((item) => item.key.toLowerCase().includes(search)) + } + if (params?.status) { + result = result.filter((item) => item.status === params.status) + } + const page = params?.page ?? 1 + const perPage = (params?.per_page ?? result.length) || 50 + const start = (page - 1) * perPage + const paged = result.slice(start, start + perPage) + return { + result: paged, + result_info: { + count: paged.length, + page, + per_page: perPage, + total_count: result.length + } + } + }, + upload: uploadItem, + async uploadAndPoll( + name: string, + content: ReadableStream | Blob | string, + uploadOptions?: AiSearchUploadItemOptions + ): Promise { + return uploadItem(name, content, uploadOptions) + }, + get(itemId: string): AiSearchItem { + return createItemHandle(itemId) + }, + async delete(itemId: string): Promise { + items.delete(itemId) + } + } as AiSearchItems + + const createJobHandle = (jobId: string): AiSearchJob => + ({ + async info(): Promise { + const job = jobs.get(jobId) + if (!job) { + throw new Error(`Mock AI Search job "${jobId}" was not found.`) + } + return cloneInfo(job) + }, + async logs(params?: AiSearchJobLogsParams): Promise { + const perPage = params?.per_page ?? 50 + return { + result: [ + { + id: 1, + message: `Mock job ${jobId}`, + message_type: 0, + created_at: Date.parse(MOCK_TIMESTAMP) + } + ].slice(0, perPage), + result_info: { + count: 1, + page: params?.page ?? 1, + per_page: perPage, + total_count: 1 + } + } + }, + async cancel(): Promise { + const job = jobs.get(jobId) + if (!job) { + throw new Error(`Mock AI Search job "${jobId}" was not found.`) + } + const updated = { + ...job, + ended_at: MOCK_TIMESTAMP, + end_reason: 'cancelled' + } + jobs.set(jobId, updated) + return cloneInfo(updated) + } + }) as AiSearchJob + + const jobsApi: AiSearchJobs = { + async list(params?: AiSearchListJobsParams): Promise { + const allJobs = Array.from(jobs.values()).map((job) => cloneInfo(job)) + const page = params?.page ?? 1 + const perPage = (params?.per_page ?? allJobs.length) || 50 + const start = (page - 1) * perPage + const result = allJobs.slice(start, start + perPage) + return { + result, + result_info: { + count: result.length, + page, + per_page: perPage, + total_count: allJobs.length + } + } + }, + async create(params?: AiSearchCreateJobParams): Promise { + const id = `job-${++jobSequence}` + const job: AiSearchJobInfo = { + id, + source: 'user', + description: params?.description, + started_at: MOCK_TIMESTAMP + } + jobs.set(id, job) + return cloneInfo(job) + }, + get(jobId: string): AiSearchJob { + return createJobHandle(jobId) + } + } as AiSearchJobs + + return { + async search(params: AiSearchSearchRequest): Promise { + return findMatches(params) + }, + async chatCompletions( + params: AiSearchChatCompletionsRequest + ): Promise { + const search = findMatches({ + messages: params.messages, + ai_search_options: params.ai_search_options + }) + const content = + typeof options.chatMessage === 'function' + ? options.chatMessage(search.chunks, params) + : (options.chatMessage ?? + (search.chunks.map((chunk) => chunk.text).join('\n') || + 'No matching offline AI Search chunks.')) + + if (params.stream) { + const payload = JSON.stringify({ + choices: [{ delta: { content } }], + chunks: search.chunks + }) + return streamFromText(`data: ${payload}\n\n`) + } + + return { + id: 'mock-ai-search-chat', + object: 'chat.completion', + model: params.model ?? 'mock-ai-search', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + } + } + ], + chunks: search.chunks + } + }, + async update(config: Partial): Promise { + info = { + ...info, + ...config, + modified_at: MOCK_TIMESTAMP + } + return cloneInfo(info) + }, + async info(): Promise { + return cloneInfo(info) + }, + async stats(): Promise { + const stats: AiSearchStatsResponse = { + queued: 0, + running: 0, + completed: 0, + error: 0, + skipped: 0, + outdated: 0, + last_activity: MOCK_TIMESTAMP, + engine: { + vectorize: { + vectorsCount: items.size, + dimensions: 0 + }, + r2: { + payloadSizeBytes: Array.from(items.values()).reduce( + (sum, item) => sum + item.info.file_size!, + 0 + ), + metadataSizeBytes: 0, + objectCount: items.size + } + } + } + for (const item of items.values()) { + stats[item.info.status] = (stats[item.info.status] ?? 0) + 1 + } + return stats + }, + get items(): AiSearchItems { + return itemsApi + }, + get jobs(): AiSearchJobs { + return jobsApi + }, + _getSearches(): AiSearchSearchRequest[] { + return [...searches] + }, + _getItems(): AiSearchItemInfo[] { + return Array.from(items.values()).map((item) => cloneInfo(item.info)) + }, + _getJobs(): AiSearchJobInfo[] { + return Array.from(jobs.values()).map((job) => cloneInfo(job)) + } + } as MockAISearchInstance +} + +/** + * Creates a deterministic AI Search namespace binding for pure unit tests. + */ +export function createMockAISearchNamespace( + options: MockAISearchNamespaceOptions = {} +): MockAISearchNamespace { + const namespaceName = options.namespace ?? 'default' + const instances = new Map() + + for (const [name, instanceOptions] of Object.entries(options.instances ?? {})) { + instances.set( + name, + isAISearchInstance(instanceOptions) + ? instanceOptions + : createMockAISearchInstance({ + id: name, + namespace: namespaceName, + ...instanceOptions + }) + ) + } + + const getInstance = (name: string): AiSearchInstance => { + const instance = instances.get(name) + if (!instance) { + throw new Error(`Mock AI Search namespace has no instance named "${name}".`) + } + return instance + } + + return { + get(name: string): AiSearchInstance { + return getInstance(name) + }, + async list(params?: AiSearchListInstancesParams): Promise { + let result = await Promise.all( + Array.from(instances.entries()).map(async ([name, instance]) => ({ + ...(await instance.info()), + namespace: namespaceName, + id: name + })) + ) + if (params?.search) { + const search = params.search.toLowerCase() + result = result.filter((instance) => instance.id.toLowerCase().includes(search)) + } + const page = params?.page ?? 1 + const perPage = (params?.per_page ?? result.length) || 50 + const start = (page - 1) * perPage + const paged = result.slice(start, start + perPage) + return { + result: paged, + result_info: { + count: paged.length, + page, + per_page: perPage, + total_count: result.length + } + } + }, + async create(config: AiSearchConfig): Promise { + const instance = createMockAISearchInstance({ + id: config.id, + namespace: namespaceName, + info: config + }) + instances.set(config.id, instance) + return instance + }, + async delete(name: string): Promise { + instances.delete(name) + }, + async search(params: AiSearchMultiSearchRequest): Promise { + const query = extractSearchQuery(params) + const chunks: AISearchMultiChunk[] = [] + const errors: AiSearchMultiSearchError[] = [] + + for (const instanceId of params.ai_search_options.instance_ids) { + const instance = instances.get(instanceId) + if (!instance) { + errors.push({ + instance_id: instanceId, + message: `Mock AI Search namespace has no instance named "${instanceId}".` + }) + continue + } + + const response = + 'query' in params + ? await instance.search({ query, ai_search_options: params.ai_search_options }) + : await instance.search({ + messages: params.messages, + ai_search_options: params.ai_search_options + }) + chunks.push( + ...response.chunks.map((chunk) => ({ + ...chunk, + instance_id: instanceId + })) + ) + } + + return { + search_query: query, + chunks, + ...(errors.length > 0 && { errors }) + } + }, + async chatCompletions( + params: AiSearchMultiChatCompletionsRequest + ): Promise { + const search = await (this as AiSearchNamespace).search({ + messages: params.messages as AiSearchMessage[], + ai_search_options: params.ai_search_options + }) + const content = + search.chunks.map((chunk) => chunk.text).join('\n') || + 'No matching offline AI Search chunks.' + + if (params.stream) { + return streamFromText(`data: ${JSON.stringify({ chunks: search.chunks })}\n\n`) + } + + return { + id: 'mock-ai-search-namespace-chat', + object: 'chat.completion', + model: params.model ?? 'mock-ai-search', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + } + } + ], + chunks: search.chunks, + errors: search.errors + } + }, + _getInstances(): string[] { + return Array.from(instances.keys()) + } + } as MockAISearchNamespace +} diff --git a/packages/devflare/src/test/binding-hints.ts b/packages/devflare/src/test/binding-hints.ts new file mode 100644 index 0000000..f5aab0e --- /dev/null +++ b/packages/devflare/src/test/binding-hints.ts @@ -0,0 +1,68 @@ +// ============================================================================= +// Binding Hints โ€” Shared extractor for test contexts +// ============================================================================= + +import type { DevflareConfig } from '../config' +import type { BindingHints } from '../bridge/proxy' + +/** + * Derive `BindingHints` from a resolved Devflare config. + * + * Used by both `createTestContext` (simple-context) and `createBridgeTestContext` + * (bridge-context) to avoid duplicating the mapping between config sections and + * hint kinds. + */ +export function extractBindingHints(config: DevflareConfig): BindingHints { + const hints: BindingHints = {} + + if (config.bindings?.kv) { + for (const name of Object.keys(config.bindings.kv)) { + hints[name] = 'kv' + } + } + if (config.bindings?.r2) { + for (const name of Object.keys(config.bindings.r2)) { + hints[name] = 'r2' + } + } + if (config.bindings?.d1) { + for (const name of Object.keys(config.bindings.d1)) { + hints[name] = 'd1' + } + } + if (config.bindings?.durableObjects) { + for (const name of Object.keys(config.bindings.durableObjects)) { + hints[name] = 'do' + } + } + if (config.bindings?.services) { + for (const name of Object.keys(config.bindings.services)) { + hints[name] = 'service' + } + } + if (config.bindings?.queues?.consumers) { + for (const consumer of config.bindings.queues.consumers) { + hints[consumer.queue] = 'queue' + } + } + if (config.bindings?.queues?.producers) { + for (const name of Object.keys(config.bindings.queues.producers)) { + hints[name] = 'queue' + } + } + if (config.bindings?.ai) { + hints[config.bindings.ai.binding] = 'ai' + } + if (config.bindings?.sendEmail) { + for (const name of Object.keys(config.bindings.sendEmail)) { + hints[name] = 'sendEmail' + } + } + if (config.bindings?.workflows) { + for (const name of Object.keys(config.bindings.workflows)) { + hints[name] = 'workflow' + } + } + + return hints +} diff --git a/packages/devflare/src/test/cf.ts b/packages/devflare/src/test/cf.ts new file mode 100644 index 0000000..08b56b0 --- /dev/null +++ b/packages/devflare/src/test/cf.ts @@ -0,0 +1,163 @@ +// ============================================================================= +// Cloudflare Test Helpers โ€” Unified API for testing all handler types +// ============================================================================= +// Usage: +// import { cf } from 'devflare/test' +// +// // Email handlers +// await cf.email.send({ from: '...', to: '...', subject: '...', body: '...' }) +// cf.email.onReceive((msg) => console.log('Sent:', msg)) +// +// // Queue handlers +// await cf.queue.trigger([{ type: 'process', data: {} }]) +// await cf.queue.send({ type: 'cleanup' }) +// +// // Scheduled handlers +// await cf.scheduled.trigger('0 */6 * * *') +// +// // Worker (fetch) handlers +// const response = await cf.worker.fetch(new Request('http://localhost/')) +// const response = await cf.worker.get('/api/users') +// const response = await cf.worker.post('/api/users', { name: 'Alice' }) +// +// // Tail handlers +// await cf.tail.trigger([{ scriptName: 'my-worker', logs: [...] }]) +// ============================================================================= + +import { email } from './email' +import { queue } from './queue' +import { scheduled } from './scheduled' +import { worker } from './worker' +import { tail } from './tail' + +// Re-export individual helpers for tree-shaking +export { email } from './email' +export { queue } from './queue' +export { scheduled } from './scheduled' +export { worker } from './worker' +export { tail } from './tail' + +// Re-export types +export type { EmailSendOptions, ReceivedEmail, EmailReceiveCallback } from './email' +export type { QueueMessageOptions, QueueTriggerResult } from './queue' +export type { ScheduledTriggerOptions, ScheduledTriggerResult } from './scheduled' +export type { WorkerFetchOptions } from './worker' +export type { TraceItemOptions, TailTriggerResult } from './tail' + +/** + * Unified Cloudflare test helpers. + * + * Provides a consistent API for triggering the main Cloudflare Worker handler surfaces: + * - `cf.email` โ€” Email helper surface with a direct-handler path and a best-effort local endpoint fallback + * - `cf.queue` โ€” Queue consumer testing + * - `cf.scheduled` โ€” Cron/scheduled handler testing + * - `cf.worker` โ€” Fetch handler testing + * - `cf.tail` โ€” Tail helper surface (uses `files.tail`, or auto-detects `src/tail.ts` when present) + * + * The helpers use the real Miniflare-backed bindings created by `createTestContext()`, + * but several helper surfaces still synthesize event/controller objects around those + * bindings rather than replaying the full Cloudflare runtime dispatch path. + * They still install the active Devflare event into AsyncLocalStorage before + * invoking user code, so getters such as `getFetchEvent()` and `getQueueEvent()` + * work inside the triggered handler. + * + * @example + * ```ts + * import { describe, test, expect, beforeAll, afterAll } from 'bun:test' + * import { createTestContext, cf } from 'devflare/test' + * import { env } from 'devflare' + * + * beforeAll(() => createTestContext()) + * afterAll(() => env.dispose()) + * + * describe('My Worker', () => { + * test('fetch handler', async () => { + * const response = await cf.worker.get('/api/health') + * expect(response.status).toBe(200) + * }) + * + * test('queue handler', async () => { + * // Trigger queue handler with messages + * const result = await cf.queue.trigger([ + * { type: 'process', data: { id: 1 } } + * ]) + * expect(result.acked).toHaveLength(1) + * + * // Verify side effects in real KV + * const stored = await env.RESULTS.get('result:1') + * expect(stored).toBeDefined() + * }) + * + * test('scheduled handler', async () => { + * await cf.scheduled.trigger('0 0 * * 1') // Weekly Monday cron + * + * // Verify scheduled job ran + * const report = await env.RESULTS.get('report:weekly') + * expect(report).toBeDefined() + * }) + * }) + * ``` + */ +export const cf = { + /** + * Email helper surface. + * + * - `cf.email.send(options)` โ€” Send a raw email through the helper + * - `cf.email.onReceive(callback)` โ€” Observe outgoing emails when runtime wiring records them + * - `cf.email.getSentEmails()` โ€” Read recorded outgoing emails + * - `cf.email.clearSentEmails()` โ€” Clear recorded outgoing email history + * + * When `createTestContext()` has configured an email handler, this invokes it directly. + * Otherwise it attempts the local `/cdn-cgi/handler/email` endpoint when the + * runtime exposes it. + */ + email, + + /** + * Queue consumer testing. + * + * - `cf.queue.trigger(messages)` โ€” Trigger queue handler with message batch + * - `cf.queue.send(message)` โ€” Convenience for single message + * + * Returns result with `acked`, `retried`, `failed` message IDs. + */ + queue, + + /** + * Scheduled (cron) handler testing. + * + * - `cf.scheduled.trigger(cron?)` โ€” Trigger scheduled handler + * + * Pass a cron expression to test cron-specific logic: + * @example + * await cf.scheduled.trigger('0 *โ€‹/6 * * *') // Every 6 hours + * await cf.scheduled.trigger('0 0 * * 1') // Weekly Monday + */ + scheduled, + + /** + * Fetch (HTTP) handler testing. + * + * - `cf.worker.fetch(request, options)` โ€” Full fetch with Request object + * - `cf.worker.get(path, headers)` โ€” GET shorthand + * - `cf.worker.post(path, body, headers)` โ€” POST shorthand + * - `cf.worker.put(path, body, headers)` โ€” PUT shorthand + * - `cf.worker.patch(path, body, headers)` โ€” PATCH shorthand + * - `cf.worker.delete(path, headers)` โ€” DELETE shorthand + * + * `cf.worker` dispatches through both `src/fetch.ts` and built-in + * `src/routes/**` file routes when they are present. + */ + worker, + + /** + * Tail helper surface. + * + * - `cf.tail.trigger(events)` โ€” Trigger tail handler with trace items + * - `cf.tail.create(options)` โ€” Create a TraceItem with defaults + * + * When `createTestContext()` finds `files.tail` or `src/tail.ts`, + * `cf.tail.trigger()` is wired automatically. + */ + tail +} diff --git a/packages/devflare/src/test/containers.ts b/packages/devflare/src/test/containers.ts new file mode 100644 index 0000000..5be84d9 --- /dev/null +++ b/packages/devflare/src/test/containers.ts @@ -0,0 +1,743 @@ +import { createHash, randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createConnection } from 'node:net' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { execa } from 'execa' +import { type ContainerConfig, type DevflareConfig, loadConfig, resolveConfigEnvVars } from '../config' +import { applyLocalDevVarsToConfig } from '../config/local-dev-vars' +import { findNearestConfig, getAvailablePort, getCallerDirectory } from './simple-context-paths' + +export type ContainerEngineName = 'docker' | 'podman' +export type ContainerEnginePreference = ContainerEngineName | 'auto' + +export interface ContainerCommandResult { + exitCode: number + stdout: string + stderr: string +} + +export interface ContainerCommandRunner { + exec( + command: string, + args: string[], + options?: { + cwd?: string + env?: Record + timeoutMs?: number + } + ): Promise +} + +export interface ContainerEngineCheck { + engine: ContainerEngineName + available: boolean + reason?: string +} + +export type ContainerEngineStatus = + | { + available: true + engine: ContainerEngineName + checked: ContainerEngineCheck[] + } + | { + available: false + reason: string + checked: ContainerEngineCheck[] + } + +export interface DetectContainerEngineOptions { + engine?: ContainerEnginePreference + runner?: ContainerCommandRunner +} + +export interface ContainerSkipReasonOptions extends DetectContainerEngineOptions { + env?: Record +} + +export interface ContainerManagerOptions extends DetectContainerEngineOptions { + cwd?: string + env?: Record + allocatePort?: () => Promise + waitForPort?: (host: string, port: number, timeoutMs: number) => Promise + fetch?: typeof fetch +} + +export interface StartContainerOptions { + image?: string + configPath?: string + port: number + hostPort?: number + host?: string + instance?: string + envVars?: Record + entrypoint?: string[] + command?: string[] + offline?: boolean + waitForReady?: boolean + readyTimeoutMs?: number +} + +export interface LocalContainerState { + status: string + running: boolean + exitCode?: number +} + +interface ResolvedContainerStart { + configDir: string + image: string + config?: ContainerConfig +} + +interface PreparedImage { + image: string +} + +export interface DevflareContainerInstance { + readonly name: string + readonly id: string + readonly className: string + readonly engine: ContainerEngineName + readonly host: string + readonly hostPort: number + readonly port: number + fetch(input: string | URL | Request, init?: RequestInit): Promise + logs(): Promise + getState(): Promise + stop(): Promise + destroy(): Promise +} + +export interface ContainerManager { + detectEngine(options?: DetectContainerEngineOptions): Promise + start(className: string, options: StartContainerOptions): Promise + stopAll(): Promise +} + +export const realContainerCommandRunner: ContainerCommandRunner = { + async exec(command, args, options = {}) { + try { + const result = await execa(command, args, { + cwd: options.cwd, + env: options.env, + reject: false, + timeout: options.timeoutMs ?? 15_000 + }) + + return { + exitCode: result.exitCode ?? 0, + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? '') + } + } catch (error) { + const err = error as { + exitCode?: number + stdout?: unknown + stderr?: unknown + message?: string + } + return { + exitCode: err.exitCode ?? 1, + stdout: String(err.stdout ?? ''), + stderr: String(err.stderr ?? err.message ?? 'Container command failed') + } + } + } +} + +export async function detectContainerEngine( + options: DetectContainerEngineOptions = {} +): Promise { + const runner = options.runner ?? realContainerCommandRunner + const candidates: ContainerEngineName[] = + options.engine && options.engine !== 'auto' ? [options.engine] : ['docker', 'podman'] + const checked: ContainerEngineCheck[] = [] + + for (const engine of candidates) { + let result: ContainerCommandResult + try { + result = await runner.exec(engine, ['info'], { timeoutMs: 10_000 }) + } catch (error) { + result = { + exitCode: 1, + stdout: '', + stderr: error instanceof Error ? error.message : String(error) + } + } + + if (result.exitCode === 0) { + checked.push({ engine, available: true }) + return { available: true, engine, checked } + } + + checked.push({ + engine, + available: false, + reason: formatCommandFailure(result) + }) + } + + return { + available: false, + reason: checked + .map((check) => `${check.engine}: ${check.reason ?? 'not available'}`) + .join('; '), + checked + } +} + +export async function getContainerSkipReason( + options: ContainerSkipReasonOptions = {} +): Promise { + const env = options.env ?? process.env + if (!isTruthyEnvFlag(env.DEVFLARE_CONTAINER_TESTS)) { + return 'Container tests require DEVFLARE_CONTAINER_TESTS=1 because they launch local Docker/Podman containers.' + } + + const status = await detectContainerEngine(options) + if (!status.available) { + return `No reachable Docker or Podman engine found: ${status.reason}` + } + + return null +} + +export function createContainerManager(options: ContainerManagerOptions = {}): ContainerManager { + const active = new Set() + + const getRunner = () => options.runner ?? realContainerCommandRunner + + const manager: ContainerManager = { + detectEngine(engineOptions = {}) { + return detectContainerEngine({ + engine: engineOptions.engine ?? options.engine, + runner: engineOptions.runner ?? getRunner() + }) + }, + + async start(className, startOptions) { + const cwd = options.cwd ?? getCallerDirectory() + const resolved = await resolveContainerStart(className, startOptions, cwd) + const status = await manager.detectEngine() + const engine = getAvailableContainerEngine(status, className) + const runner = getRunner() + const offline = startOptions.offline ?? true + const prepared = await prepareImage({ + engine, + runner, + image: resolved.image, + className, + configDir: resolved.configDir, + imageBuildContext: resolved.config?.imageBuildContext, + offline + }) + const host = startOptions.host ?? '127.0.0.1' + const hostPort = startOptions.hostPort ?? (await (options.allocatePort ?? getAvailablePort)()) + const containerName = makeContainerName(className, startOptions.instance) + const runArgs = buildRunArgs({ + name: containerName, + className, + image: prepared.image, + host, + hostPort, + containerPort: startOptions.port, + envVars: startOptions.envVars, + entrypoint: startOptions.entrypoint, + command: startOptions.command + }) + const runResult = await runner.exec(engine, runArgs, { + cwd: resolved.configDir, + env: options.env ?? process.env + }) + + if (runResult.exitCode !== 0) { + throw new Error( + `Failed to start Devflare container "${className}": ${formatCommandFailure(runResult)}` + ) + } + + const instance = new LocalDevflareContainer({ + id: runResult.stdout.trim() || containerName, + name: containerName, + className, + engine, + host, + hostPort, + port: startOptions.port, + runner, + fetchImpl: options.fetch ?? fetch, + onDispose: (container) => active.delete(container) + }) + active.add(instance) + + await waitForContainerReadiness(instance, startOptions, options.waitForPort ?? waitForTcpPort) + + return instance + }, + + async stopAll() { + await Promise.all([...active].map((container) => container.stop())) + } + } + + return manager +} + +const defaultContainerManager = createContainerManager() + +export const containers = defaultContainerManager + +export async function stopActiveContainers(): Promise { + await defaultContainerManager.stopAll() +} + +function getAvailableContainerEngine( + status: ContainerEngineStatus, + className: string +): ContainerEngineName { + if (!status.available) { + throw new Error(`Cannot start Devflare container "${className}": ${status.reason}`) + } + return status.engine +} + +async function waitForContainerReadiness( + instance: LocalDevflareContainer, + options: StartContainerOptions, + waitForPort: (host: string, port: number, timeoutMs: number) => Promise +): Promise { + if (!(options.waitForReady ?? true)) { + return + } + + try { + await waitForPort(instance.host, instance.hostPort, options.readyTimeoutMs ?? 20_000) + } catch (error) { + await instance.destroy() + throw error + } +} + +async function resolveContainerStart( + className: string, + options: StartContainerOptions, + cwd: string +): Promise { + if (options.image) { + return { + configDir: cwd, + image: options.image + } + } + + const { config, configDir } = await loadContainerConfig(options.configPath, cwd) + const container = config.containers?.find( + (candidate) => candidate.className === className || candidate.name === className + ) + + if (!container) { + throw new Error(`Container "${className}" was not found in devflare config.`) + } + + return { + configDir, + image: container.image, + config: container + } +} + +async function loadContainerConfig( + configPath: string | undefined, + cwd: string +): Promise<{ config: DevflareConfig; configDir: string }> { + const absolutePath = configPath ? resolve(cwd, configPath) : await findNearestConfig(cwd) + + if (!absolutePath) { + throw new Error( + `Could not find a devflare config file for container lookup. Searched upward from: ${cwd}` + ) + } + + const configDir = dirname(absolutePath) + const loadedConfig = await loadConfig({ + cwd: configDir, + configFile: basename(absolutePath) + }) + const envResolvedConfig = await resolveConfigEnvVars(loadedConfig, { + cwd: configDir, + configPath: absolutePath, + mode: 'dev' + }) + const config = await applyLocalDevVarsToConfig(envResolvedConfig, { + cwd: configDir, + configPath: absolutePath + }) + + return { config, configDir } +} + +async function prepareImage(options: { + engine: ContainerEngineName + runner: ContainerCommandRunner + image: string + className: string + configDir: string + imageBuildContext?: string + offline: boolean +}): Promise { + if (looksLikeLocalPath(options.image)) { + return { + image: await buildLocalImage(options) + } + } + + const inspect = await options.runner.exec(options.engine, ['image', 'inspect', options.image], { + cwd: options.configDir + }) + if (inspect.exitCode === 0) { + return { image: options.image } + } + + if (options.offline) { + throw new Error( + `Container image "${options.image}" is not present locally. Devflare container tests are offline-first; pull/build the image ahead of time or pass offline: false.` + ) + } + + const pull = await options.runner.exec(options.engine, ['pull', options.image], { + cwd: options.configDir + }) + if (pull.exitCode !== 0) { + throw new Error( + `Failed to pull container image "${options.image}": ${formatCommandFailure(pull)}` + ) + } + + return { image: options.image } +} + +async function buildLocalImage(options: { + engine: ContainerEngineName + runner: ContainerCommandRunner + image: string + className: string + configDir: string + imageBuildContext?: string + offline: boolean +}): Promise { + const imagePath = resolve(options.configDir, options.image) + const imageStat = await stat(imagePath) + const dockerfile = imageStat.isDirectory() ? join(imagePath, 'Dockerfile') : imagePath + const context = resolve( + options.configDir, + options.imageBuildContext ?? (imageStat.isDirectory() ? options.image : dirname(options.image)) + ) + + if (!existsSync(dockerfile)) { + throw new Error(`Container Dockerfile does not exist: ${dockerfile}`) + } + + const tag = makeLocalImageTag(options.className, options.configDir, options.image) + const args = [ + 'build', + ...(options.offline ? [getOfflineBuildPullArg(options.engine)] : []), + '-t', + tag, + '-f', + dockerfile, + context + ] + const result = await options.runner.exec(options.engine, args, { + cwd: options.configDir + }) + + if (result.exitCode !== 0) { + throw new Error( + `Failed to build local container image "${options.image}": ${formatCommandFailure(result)}` + ) + } + + return tag +} + +function buildRunArgs(options: { + name: string + className: string + image: string + host: string + hostPort: number + containerPort: number + envVars?: Record + entrypoint?: string[] + command?: string[] +}): string[] { + const args = [ + 'run', + '-d', + '--name', + options.name, + '--label', + 'devflare.managed=true', + '--label', + `devflare.container.class=${options.className}`, + '-p', + `${options.host}:${options.hostPort}:${options.containerPort}` + ] + + for (const [key, value] of Object.entries(options.envVars ?? {})) { + args.push('-e', `${key}=${value}`) + } + + const command = [...(options.command ?? [])] + if (options.entrypoint && options.entrypoint.length > 0) { + args.push('--entrypoint', options.entrypoint[0]) + command.unshift(...options.entrypoint.slice(1)) + } + + args.push(options.image, ...command) + return args +} + +class LocalDevflareContainer implements DevflareContainerInstance { + readonly id: string + readonly name: string + readonly className: string + readonly engine: ContainerEngineName + readonly host: string + readonly hostPort: number + readonly port: number + private readonly runner: ContainerCommandRunner + private readonly fetchImpl: typeof fetch + private readonly onDispose: (container: LocalDevflareContainer) => void + private disposed = false + + constructor(options: { + id: string + name: string + className: string + engine: ContainerEngineName + host: string + hostPort: number + port: number + runner: ContainerCommandRunner + fetchImpl: typeof fetch + onDispose: (container: LocalDevflareContainer) => void + }) { + this.id = options.id + this.name = options.name + this.className = options.className + this.engine = options.engine + this.host = options.host + this.hostPort = options.hostPort + this.port = options.port + this.runner = options.runner + this.fetchImpl = options.fetchImpl + this.onDispose = options.onDispose + } + + fetch(input: string | URL | Request, init?: RequestInit): Promise { + return fetchWithStartupRetries(this.fetchImpl, this.toLocalRequest(input, init)) + } + + async logs(): Promise { + const result = await this.runner.exec(this.engine, ['logs', this.name]) + if (result.exitCode !== 0) { + throw new Error( + `Failed to read logs for Devflare container "${this.name}": ${formatCommandFailure(result)}` + ) + } + + return result.stdout + } + + async getState(): Promise { + const result = await this.runner.exec(this.engine, [ + 'inspect', + '--format', + '{{json .State}}', + this.name + ]) + if (result.exitCode !== 0) { + throw new Error( + `Failed to inspect Devflare container "${this.name}": ${formatCommandFailure(result)}` + ) + } + + const parsed = JSON.parse(result.stdout || '{}') as { + Status?: string + Running?: boolean + ExitCode?: number + } + + return { + status: parsed.Status ?? (parsed.Running ? 'running' : 'stopped'), + running: Boolean(parsed.Running), + ...(typeof parsed.ExitCode === 'number' && { exitCode: parsed.ExitCode }) + } + } + + async stop(): Promise { + if (this.disposed) return + try { + await this.runner.exec(this.engine, ['stop', this.name]) + } finally { + await this.runner.exec(this.engine, ['rm', '-f', this.name]) + this.disposed = true + this.onDispose(this) + } + } + + async destroy(): Promise { + if (this.disposed) return + await this.runner.exec(this.engine, ['rm', '-f', this.name]) + this.disposed = true + this.onDispose(this) + } + + private toLocalRequest(input: string | URL | Request, init?: RequestInit): Request { + const base = `http://${this.host}:${this.hostPort}/` + if (input instanceof Request) { + const source = init ? new Request(input, init) : input + const sourceUrl = new URL(source.url) + const localUrl = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, base) + return new Request(localUrl, { + method: source.method, + headers: source.headers, + body: source.body, + redirect: source.redirect, + signal: source.signal + }) + } + + const sourceUrl = new URL(String(input), base) + const localUrl = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, base) + return new Request(localUrl, init) + } +} + +async function fetchWithStartupRetries( + fetchImpl: typeof fetch, + request: Request +): Promise { + if (!canRetryRequest(request)) { + return fetchImpl(request) + } + + const deadline = Date.now() + 5_000 + let lastError: unknown + + while (Date.now() < deadline) { + try { + return await fetchImpl(request.clone()) + } catch (error) { + if (!isTransientContainerFetchError(error)) { + throw error + } + lastError = error + await delay(100) + } + } + + throw lastError +} + +function canRetryRequest(request: Request): boolean { + return (request.method === 'GET' || request.method === 'HEAD') && request.body === null +} + +function isTransientContainerFetchError(error: unknown): boolean { + const value = error as { + code?: unknown + errno?: unknown + cause?: { code?: unknown } + message?: string + } + const code = value.code ?? value.cause?.code + if ( + code === 'ECONNRESET' || + code === 'ECONNREFUSED' || + code === 'EPIPE' || + code === 'UND_ERR_SOCKET' + ) { + return true + } + + return typeof value.message === 'string' && ( + value.message.includes('socket connection was closed') || + value.message.includes('fetch failed') + ) +} + +async function waitForTcpPort(host: string, port: number, timeoutMs: number): Promise { + const start = Date.now() + let lastError: unknown + + while (Date.now() - start < timeoutMs) { + try { + await connectOnce(host, port) + return + } catch (error) { + lastError = error + await delay(100) + } + } + + const message = lastError instanceof Error ? lastError.message : 'timed out' + throw new Error(`Timed out waiting for container port ${host}:${port}: ${message}`) +} + +function connectOnce(host: string, port: number): Promise { + return new Promise((resolveConnection, rejectConnection) => { + const socket = createConnection({ host, port }) + socket.once('connect', () => { + socket.destroy() + resolveConnection() + }) + socket.once('error', rejectConnection) + }) +} + +function looksLikeLocalPath(image: string): boolean { + return ( + image === 'Dockerfile' || + image.startsWith('.') || + image.startsWith('/') || + image.startsWith('\\') || + isAbsolute(image) || + image.endsWith('/Dockerfile') || + image.endsWith('\\Dockerfile') + ) +} + +function getOfflineBuildPullArg(engine: ContainerEngineName): string { + return engine === 'podman' ? '--pull=never' : '--pull=false' +} + +function makeLocalImageTag(className: string, configDir: string, image: string): string { + const hash = createHash('sha256').update(`${configDir}:${image}`).digest('hex').slice(0, 12) + return `devflare-local-${sanitizeName(className)}:${hash}` +} + +function makeContainerName(className: string, instance: string | undefined): string { + return `devflare-${sanitizeName(className)}-${sanitizeName(instance ?? 'default')}-${randomUUID().slice(0, 8)}` +} + +function sanitizeName(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'container' + ) +} + +function formatCommandFailure(result: ContainerCommandResult): string { + return (result.stderr || result.stdout || `exit code ${result.exitCode}`).trim() +} + +function isTruthyEnvFlag(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true' || value?.toLowerCase() === 'yes' +} diff --git a/packages/devflare/src/test/email.ts b/packages/devflare/src/test/email.ts new file mode 100644 index 0000000..0179459 --- /dev/null +++ b/packages/devflare/src/test/email.ts @@ -0,0 +1,349 @@ +// ============================================================================= +// Email Test Helper โ€” Test email handlers in Bun tests +// ============================================================================= +// Usage: +// import { email } from 'devflare/test' +// +// // Send a raw email through the helper +// await email.send({ +// from: 'sender@example.com', +// to: 'recipient@example.com', +// subject: 'Test Email', +// body: 'Hello, world!' +// }) +// +// // Observe outgoing emails when runtime wiring records them +// const unsub = email.onReceive((msg) => { +// console.log('Received:', msg) +// }) +// ============================================================================= + +import { join } from 'path' +import { createEmailEvent, runWithEventContext } from '../runtime' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface EmailSendOptions { + /** Sender email address */ + from: string + /** Recipient email address */ + to: string + /** Email subject */ + subject?: string + /** Email body (text or HTML) */ + body?: string + /** Raw email content (overrides subject/body) */ + raw?: string + /** Additional headers */ + headers?: Record +} + +export interface ReceivedEmail { + /** Email type: 'send', 'forward', or 'reply' */ + type: 'send' | 'forward' | 'reply' + /** Sender email address */ + from: string + /** Recipient email address */ + to: string + /** Raw email content path (local file in dev) */ + rawPath?: string + /** Raw email content */ + raw?: string + /** Message ID if available */ + messageId?: string + /** Timestamp */ + timestamp: Date +} + +export type EmailReceiveCallback = (email: ReceivedEmail) => void + +// ----------------------------------------------------------------------------- +// Global State +// ----------------------------------------------------------------------------- + +let miniflarePort = 8787 +let emailListeners: EmailReceiveCallback[] = [] +let sentEmails: ReceivedEmail[] = [] +let emailHandlerPath: string | null = null +let configDir: string | null = null +let testEnvGetter: (() => Record) | null = null + +// ----------------------------------------------------------------------------- +// Configuration +// ----------------------------------------------------------------------------- + +/** + * Configure the email test helper + * @internal + */ +export function configureEmail(options: { + port?: number + handlerPath?: string | null + configDir?: string + getEnv?: () => Record +} = {}): void { + if (options.port) { + miniflarePort = options.port + } + + emailHandlerPath = options.handlerPath ?? emailHandlerPath + configDir = options.configDir ?? configDir + testEnvGetter = options.getEnv ?? testEnvGetter +} + +// ----------------------------------------------------------------------------- +// Email Builder +// ----------------------------------------------------------------------------- + +/** + * Build a raw email string from options + */ +function buildRawEmail(options: EmailSendOptions): string { + if (options.raw) { + return options.raw + } + + const lines: string[] = [] + const messageId = `<${Date.now()}-${Math.random().toString(36).slice(2)}@devflare.dev>` + const date = new Date().toUTCString() + + lines.push(`From: ${options.from}`) + lines.push(`To: ${options.to}`) + lines.push(`Date: ${date}`) + lines.push(`Message-ID: ${messageId}`) + + if (options.subject) { + lines.push(`Subject: ${options.subject}`) + } + + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + lines.push(`${key}: ${value}`) + } + } + + lines.push('MIME-Version: 1.0') + lines.push('Content-Type: text/plain; charset=UTF-8') + lines.push('') // Empty line separates headers from body + lines.push(options.body ?? '') + + return lines.join('\r\n') +} + +function createEmailHeaders(rawEmail: string): Headers { + const headers = new Headers() + const lines = rawEmail.split(/\r?\n/) + + for (const line of lines) { + if (!line.trim()) { + break + } + + const colonIndex = line.indexOf(':') + if (colonIndex <= 0) { + continue + } + + headers.append(line.slice(0, colonIndex).trim(), line.slice(colonIndex + 1).trim()) + } + + return headers +} + +function createRawEmailStream(rawEmail: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(rawEmail)) + controller.close() + } + }) +} + +function resolveEmailHandler(module: Record): ((event: unknown) => Promise | unknown) | null { + if (typeof module.default === 'function') { + return module.default as (event: unknown) => Promise | unknown + } + + if (module.default && typeof (module.default as Record).email === 'function') { + return ((module.default as Record).email as Function).bind(module.default) as (event: unknown) => Promise | unknown + } + + if (typeof module.email === 'function') { + return module.email as (event: unknown) => Promise | unknown + } + + return null +} + +function getRecordedRawContent(raw: unknown): string | undefined { + if (typeof raw === 'string') { + return raw + } + + return undefined +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Send an incoming email through the email test helper. + * + * When `createTestContext()` has configured an email handler, this imports and + * invokes that handler directly and waits for queued `waitUntil()` work. + * Otherwise it attempts the local `/cdn-cgi/handler/email` endpoint exposed by + * compatible local runtimes. + */ +async function send(options: EmailSendOptions): Promise { + const raw = buildRawEmail(options) + + if (emailHandlerPath && configDir && testEnvGetter) { + const absolutePath = join(configDir, emailHandlerPath) + const handlerModule = await import(absolutePath) + const emailHandler = resolveEmailHandler(handlerModule) + + if (!emailHandler) { + throw new Error( + `Email handler at "${emailHandlerPath}" must export a default function or named "email" export.\n` + + + `Expected: export async function email(message) { ... }` + ) + } + + const waitUntilPromises: Promise[] = [] + const ctx: ExecutionContext = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { }, + props: {} + } + + const runtimeEnv = testEnvGetter() + const timestamp = new Date() + const message = { + from: options.from, + to: options.to, + headers: createEmailHeaders(raw), + raw: createRawEmailStream(raw), + rawSize: raw.length, + setReject(reason: string) { + throw new Error(`Email rejected: ${reason}`) + }, + async forward(rcptTo: string) { + recordSentEmail({ + type: 'forward', + from: options.from, + to: rcptTo, + raw, + timestamp + }) + }, + async reply(message: { from?: string; to?: string; raw?: unknown }) { + recordSentEmail({ + type: 'reply', + from: message.from ?? options.to, + to: message.to ?? options.from, + raw: getRecordedRawContent(message.raw), + timestamp + }) + } + } as unknown as ForwardableEmailMessage + + const emailEvent = createEmailEvent(message, runtimeEnv, ctx) + + await runWithEventContext( + emailEvent, + () => emailHandler(emailEvent) + ) + + await Promise.all(waitUntilPromises) + + return new Response(JSON.stringify({ ok: true, from: options.from, to: options.to }), { + headers: { 'Content-Type': 'application/json' } + }) + } + + const url = new URL(`http://localhost:${miniflarePort}/cdn-cgi/handler/email`) + url.searchParams.set('from', options.from) + url.searchParams.set('to', options.to) + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + body: raw + }) + + return response +} + +/** + * Register a callback for outgoing emails. + * + * This only fires when the surrounding runtime wiring records outgoing emails. + * @returns Unsubscribe function + */ +function onReceive(callback: EmailReceiveCallback): () => void { + emailListeners.push(callback) + return () => { + emailListeners = emailListeners.filter((cb) => cb !== callback) + } +} + +/** + * Get all recorded outgoing emails since test context was created + */ +function getSentEmails(): ReceivedEmail[] { + return [...sentEmails] +} + +/** + * Clear recorded outgoing email history + */ +function clearSentEmails(): void { + sentEmails = [] +} + +/** + * Add a sent email to the history + * @internal Called by helper paths that explicitly record outgoing email + * (currently direct `forward()`/`reply()` test flows). + */ +export function recordSentEmail(email: ReceivedEmail): void { + sentEmails.push(email) + for (const listener of emailListeners) { + try { + listener(email) + } catch (error) { + console.error('[devflare/test] Email listener error:', error) + } + } +} + +/** + * Reset email state + * @internal Called when test context is disposed + */ +export function resetEmailState(): void { + miniflarePort = 8787 + emailHandlerPath = null + configDir = null + testEnvGetter = null + emailListeners = [] + sentEmails = [] +} + +// ----------------------------------------------------------------------------- +// Export +// ----------------------------------------------------------------------------- + +export const email = { + send, + onReceive, + getSentEmails, + clearSentEmails +} diff --git a/packages/devflare/src/test/index.ts b/packages/devflare/src/test/index.ts new file mode 100644 index 0000000..e50a1c2 --- /dev/null +++ b/packages/devflare/src/test/index.ts @@ -0,0 +1,123 @@ +// ============================================================================= +// Test Module โ€” Public Exports +// ============================================================================= + +// Simple test context API (recommended for single-worker tests) +export { + createTestContext, + env, + type DevflareEnv, + type TestEnv +} from './simple-context' + +// Cloudflare test helpers โ€” unified API for triggering all handler types +export { cf } from './cf' +export { email } from './email' +export { queue } from './queue' +export { scheduled } from './scheduled' +export { worker } from './worker' +export { tail } from './tail' + +// Helper types +export type { EmailSendOptions, ReceivedEmail, EmailReceiveCallback } from './email' +export type { QueueMessageOptions, QueueTriggerResult } from './queue' +export type { ScheduledTriggerOptions, ScheduledTriggerResult } from './scheduled' +export type { WorkerFetchOptions } from './worker' +export type { TraceItemOptions, TailTriggerResult } from './tail' + +// Service binding resolution (internal use) +export { + hasServiceBindings, + resolveServiceBindings, + hasCrossWorkerDOs, + resolveDOBindings, + clearBundleCache, + type ResolvedWorker, + type ServiceBindingResolution, + type DOBindingResolution +} from './resolve-service-bindings' + +// Skip helper for conditional test execution +export { shouldSkip } from './should-skip' + +// Local Cloudflare Containers testing helpers +export { + containers, + createContainerManager, + detectContainerEngine, + getContainerSkipReason, + stopActiveContainers, + type ContainerCommandResult, + type ContainerCommandRunner, + type ContainerEngineCheck, + type ContainerEngineName, + type ContainerEnginePreference, + type ContainerEngineStatus, + type ContainerManager, + type ContainerManagerOptions, + type DevflareContainerInstance, + type LocalContainerState, + type StartContainerOptions +} from './containers' + +// Offline-first support matrix and config-derived pure-test env helpers +export { + createOfflineBindings, + createOfflineEnv, + describeOfflineSupport, + getOfflineSupportMatrix, + type OfflineBindingFixtures, + type OfflineBindingsResult, + type OfflineMissingFixture, + type OfflineRemoteBoundary, + type OfflineSupportEntry, + type OfflineSupportTier +} from './offline-bindings' + +// AI Search pure unit-test mocks +export { + createMockAISearchInstance, + createMockAISearchNamespace, + type MockAISearchInstance, + type MockAISearchInstanceOptions, + type MockAISearchItemFixture, + type MockAISearchNamespace, + type MockAISearchNamespaceOptions +} from './ai-search' + +// Mock utilities (for unit testing without Miniflare) +export { + createMockTestContext, + createMockKV, + createMockD1, + createMockR2, + createMockQueue, + createMockRateLimit, + createMockVersionMetadata, + createMockHyperdrive, + createMockWorkerLoader, + createMockMTLSCertificate, + createMockDispatchNamespace, + createMockWorkflow, + createMockPipeline, + createMockImagesBinding, + createMockMediaBinding, + createMockArtifacts, + createMockSecretsStoreSecret, + createMockEnv, + withTestContext, + type TestContext, + type TestContextOptions, + type MockEnvOptions, + type MockRateLimitOptions, + type MockWorkerLoaderOptions, + type MockFetchInput, + type MockFetcherHandler, + type MockDispatchNamespaceOptions, + type MockWorkflowOptions, + type MockWorkflowInstanceOptions, + type MockPipeline, + type MockImagesBindingOptions, + type MockMediaBindingOptions, + type MockArtifactsOptions +} from './utilities' diff --git a/packages/devflare/src/test/offline-bindings.ts b/packages/devflare/src/test/offline-bindings.ts new file mode 100644 index 0000000..fbf43a4 --- /dev/null +++ b/packages/devflare/src/test/offline-bindings.ts @@ -0,0 +1,629 @@ +// ============================================================================= +// Offline-First Test Binding Helpers +// ============================================================================= +// Config-derived pure-test bindings plus an explicit support matrix. This keeps +// offline tests deterministic and names remote-only product boundaries clearly. +// ============================================================================= + +import type { Pipeline } from 'cloudflare:pipelines' +import { normalizeHyperdriveBinding, type DevflareConfig } from '../config' +import { resolveLocalSecretValuesForBindings } from '../secrets/local-secrets' +import { + type MockAISearchInstanceOptions, + type MockAISearchNamespaceOptions, + createMockAISearchInstance, + createMockAISearchNamespace +} from './ai-search' +import { + type MockArtifactsOptions, + type MockDispatchNamespaceOptions, + type MockFetcherHandler, + type MockImagesBindingOptions, + type MockMediaBindingOptions, + type MockWorkerLoaderOptions, + type MockWorkflowOptions, + createMockArtifacts, + createMockDispatchNamespace, + createMockHyperdrive, + createMockImagesBinding, + createMockMTLSCertificate, + createMockMediaBinding, + createMockPipeline, + createMockRateLimit, + createMockSecretsStoreSecret, + createMockVersionMetadata, + createMockWorkerLoader, + createMockWorkflow +} from './utilities' + +export type OfflineSupportTier = 'offline-native' | 'offline-fixture' | 'remote-boundary' + +export interface OfflineSupportEntry { + service: string + tier: OfflineSupportTier + reason: string + recommendation: string +} + +export interface OfflineRemoteBoundary { + service: string + binding?: string + reason: string + recommendation: string +} + +export interface OfflineMissingFixture { + service: string + binding: string + reason: string +} + +export interface OfflineBindingFixtures { + secretsStore?: Record + workerLoaders?: Record + mtlsCertificates?: Record + dispatchNamespaces?: Record + workflows?: Record + pipelines?: Record + images?: Record + media?: Record + artifacts?: Record + aiSearch?: Record + aiSearchNamespaces?: Record + custom?: Record + hyperdrive?: Record +} + +export interface OfflineBindingsResult { + env: Record + support: Record + remoteBoundaries: OfflineRemoteBoundary[] + missingFixtures: OfflineMissingFixture[] +} + +export interface OfflineBindingOptions { + /** Project root containing `.devflare/secrets.local.json`. */ + cwd?: string + /** Read `.devflare/secrets.local.json` when `cwd` is supplied. */ + useLocalSecrets?: boolean +} + +type OfflineConfig = Partial & { + vars?: Record + bindings?: DevflareConfig['bindings'] +} + +const SUPPORT_MATRIX: Record = { + rateLimits: { + service: 'rateLimits', + tier: 'offline-native', + reason: 'Miniflare and devflare/test can simulate fixed-window RateLimit bindings locally.', + recommendation: + 'Use createOfflineEnv() or createMockRateLimit() for pure tests; use createTestContext() for Miniflare-backed tests.' + }, + versionMetadata: { + service: 'versionMetadata', + tier: 'offline-native', + reason: 'Version metadata can be deterministic in tests without Cloudflare state.', + recommendation: + 'Use createOfflineEnv() or createMockVersionMetadata() when asserting version-aware code.' + }, + secretsStore: { + service: 'secretsStore', + tier: 'offline-native', + reason: + 'Secrets Store binding shape is local-testable with local secret-store values or fixed fixtures.', + recommendation: + 'Use devflare secrets --local for dev/test values, or pass fixtures.secretsStore values for pure tests.' + }, + hyperdrive: { + service: 'hyperdrive', + tier: 'offline-native', + reason: + 'Hyperdrive can run locally through Miniflare when Devflare has a local connection string or test fixture for the target database.', + recommendation: + 'Use bindings.hyperdrive.*.localConnectionString, CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_, or fixtures.hyperdrive for offline tests.' + }, + workerLoaders: { + service: 'workerLoaders', + tier: 'offline-native', + reason: + 'Worker Loader bindings run locally through Miniflare and can use explicit Worker stubs for pure tests.', + recommendation: + 'Use createTestContext() for local WorkerLoader execution; pass fixtures.workerLoaders with a WorkerStub for pure tests.' + }, + mtlsCertificates: { + service: 'mtlsCertificates', + tier: 'offline-fixture', + reason: + 'Fetcher call paths can be tested locally, but real certificate presentation is Cloudflare/Wrangler remote behavior.', + recommendation: + 'Pass fixtures.mtlsCertificates handlers for unit tests; use remote/deployed tests for certificate presentation.' + }, + dispatchNamespaces: { + service: 'dispatchNamespaces', + tier: 'offline-fixture', + reason: + 'Tenant dispatch can be backed by explicit test fetchers, but namespace uploads and lifecycle are Cloudflare-managed.', + recommendation: + 'Pass fixtures.dispatchNamespaces workers for deterministic tenant routing tests.' + }, + workflows: { + service: 'workflows', + tier: 'offline-native', + reason: + 'Workflow binding calls can run through Miniflare or a deterministic local Workflow mock for app-level tests.', + recommendation: + 'Use createOfflineEnv() for pure tests and createTestContext() when Miniflare execution semantics matter.' + }, + pipelines: { + service: 'pipelines', + tier: 'offline-native', + reason: + 'Pipeline sends can be recorded locally; Cloudflare owns production batching and sinks.', + recommendation: + 'Use createOfflineEnv() or createMockPipeline() to assert records sent by application code.' + }, + images: { + service: 'images', + tier: 'offline-native', + reason: + 'Images has local development support and Devflare provides a low-fidelity deterministic pure mock.', + recommendation: + 'Use createOfflineEnv() for chain-shape tests; use Cloudflare for hosted image APIs and transform fidelity.' + }, + media: { + service: 'media', + tier: 'offline-native', + reason: + 'Media Transformations can run through Miniflare wiring locally, and Devflare provides a deterministic pure mock for app-level chain tests.', + recommendation: + 'Use createTestContext() for local Worker binding tests and createMockMediaBinding() for pure tests; use Cloudflare for codec/output fidelity.' + }, + artifacts: { + service: 'artifacts', + tier: 'offline-fixture', + reason: + 'Artifacts repo metadata and token flows can be modeled in memory; real Git remotes are Cloudflare-managed.', + recommendation: + 'Use createMockArtifacts() for unit tests; use Cloudflare for Git protocol and namespace access.' + }, + aiSearch: { + service: 'aiSearch', + tier: 'offline-fixture', + reason: + 'AI Search application flows can use deterministic in-memory instances and namespaces, but indexing/ranking/crawling are hosted Cloudflare behavior.', + recommendation: + 'Use createMockAISearchInstance(), createMockAISearchNamespace(), or createOfflineEnv(); use remote tests for real relevance behavior.' + }, + aiSearchNamespaces: { + service: 'aiSearchNamespaces', + tier: 'offline-fixture', + reason: + 'AI Search namespace management can be backed by an explicit in-memory instance registry for tests.', + recommendation: + 'Use fixtures.aiSearchNamespaces to model tenant instances and multi-instance searches.' + }, + containers: { + service: 'containers', + tier: 'offline-native', + reason: + 'Devflare can launch local Docker/Podman containers in explicit tests when an engine and cached images are available.', + recommendation: + 'Use devflare/test containers helpers and shouldSkip.containers; keep ordinary unit tests engine-free.' + }, + browser: { + service: 'browser', + tier: 'offline-native', + reason: + 'Cloudflare lists Browser Run as locally simulatable, while live view, HITL, recordings, and external CDP remain hosted features.', + recommendation: + 'Use Cloudflare/Wrangler Browser local development for browser execution and remote/deployed tests for hosted-only features.' + }, + ai: { + service: 'ai', + tier: 'remote-boundary', + reason: 'Workers AI inference has no local simulation in Cloudflare local development.', + recommendation: + 'Use DEVFLARE_REMOTE=1/devflare remote enable for real calls, or inject a custom fake for pure tests.' + }, + aiGateway: { + service: 'aiGateway', + tier: 'remote-boundary', + reason: + 'AI Gateway routing and logs are Cloudflare account resources reached through the Workers AI binding.', + recommendation: + 'Use remote-mode AI Gateway helpers for integration tests and custom fakes for offline unit tests.' + }, + vectorize: { + service: 'vectorize', + tier: 'remote-boundary', + reason: 'Cloudflare lists Vectorize with no local simulation.', + recommendation: + 'Use DEVFLARE_REMOTE=1/devflare remote enable for real indexes, or inject a fake Vectorize binding for pure tests.' + }, + builds: { + service: 'builds', + tier: 'remote-boundary', + reason: + 'Cloudflare Builds/Git-connected Workers are CI/CD orchestration, not a Worker runtime binding.', + recommendation: + 'Run Devflare commands inside your CI; validate Cloudflare build integration with Cloudflare/Wrangler tests.' + } +} + +function copySupport(entry: OfflineSupportEntry): OfflineSupportEntry { + return { ...entry } +} + +export function getOfflineSupportMatrix(): Record { + return Object.fromEntries( + Object.entries(SUPPORT_MATRIX).map(([service, entry]) => [service, copySupport(entry)]) + ) +} + +export function describeOfflineSupport(service: string): OfflineSupportEntry { + const entry = SUPPORT_MATRIX[service] + if (entry) { + return copySupport(entry) + } + + return { + service, + tier: 'remote-boundary', + reason: `No offline support classification exists for "${service}".`, + recommendation: + 'Treat this as a remote Cloudflare boundary until Devflare documents a local simulator or fixture.' + } +} + +function createMissingSecret(binding: string): SecretsStoreSecret { + return { + async get(): Promise { + throw new Error( + `Offline Secrets Store binding "${binding}" has no value. Pass fixtures.secretsStore.${binding} or write a local secret with devflare secrets --local.` + ) + } + } as SecretsStoreSecret +} + +function isWorkflowBinding(value: MockWorkflowOptions | Workflow): value is Workflow { + return typeof (value as { create?: unknown }).create === 'function' +} + +function isPipelineBinding(value: Pipeline | undefined): value is Pipeline { + return typeof (value as { send?: unknown } | undefined)?.send === 'function' +} + +function isImagesBinding( + value: MockImagesBindingOptions | ImagesBinding | undefined +): value is ImagesBinding { + return typeof (value as { input?: unknown } | undefined)?.input === 'function' +} + +function isHyperdriveBinding(value: string | Hyperdrive | undefined): value is Hyperdrive { + return typeof (value as { connectionString?: unknown } | undefined)?.connectionString === 'string' +} + +function isMediaBinding( + value: MockMediaBindingOptions | MediaBinding | undefined +): value is MediaBinding { + return typeof (value as { input?: unknown } | undefined)?.input === 'function' +} + +function isArtifactsBinding( + value: MockArtifactsOptions | Artifacts | undefined +): value is Artifacts { + return typeof (value as { create?: unknown } | undefined)?.create === 'function' +} + +function isAISearchInstance( + value: MockAISearchInstanceOptions | AiSearchInstance | undefined +): value is AiSearchInstance { + return typeof (value as { search?: unknown } | undefined)?.search === 'function' +} + +function isAISearchNamespace( + value: MockAISearchNamespaceOptions | AiSearchNamespace | undefined +): value is AiSearchNamespace { + return typeof (value as { get?: unknown } | undefined)?.get === 'function' +} + +function addBoundary( + remoteBoundaries: OfflineRemoteBoundary[], + service: string, + binding: string | undefined, + reason: string +) { + remoteBoundaries.push({ + service, + binding, + reason, + recommendation: describeOfflineSupport(service).recommendation + }) +} + +function addStaticBindings(env: Record, config: OfflineConfig) { + if (config.vars) { + Object.assign(env, config.vars) + } +} + +function addRateLimitBindings(env: Record, bindings: OfflineConfig['bindings']) { + for (const [name, binding] of Object.entries(bindings?.rateLimits ?? {})) { + env[name] = createMockRateLimit(binding.simple) + } +} + +function addVersionMetadataBinding( + env: Record, + bindings: OfflineConfig['bindings'] +) { + if (bindings?.versionMetadata) { + env[bindings.versionMetadata.binding] = createMockVersionMetadata() + } +} + +function getHyperdriveConnectionString( + name: string, + binding: NonNullable['hyperdrive']>[string], + fixture: string | Hyperdrive | undefined +): string | undefined { + if (typeof fixture === 'string') { + return fixture + } + + const envValue = + process.env[`CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_${name}`] ?? + process.env[`WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_${name}`] + if (envValue?.trim()) { + return envValue + } + + return normalizeHyperdriveBinding(binding).localConnectionString +} + +function addHyperdriveBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures, + missingFixtures: OfflineMissingFixture[] +) { + for (const [name, binding] of Object.entries(bindings?.hyperdrive ?? {})) { + const fixture = fixtures.hyperdrive?.[name] + if (isHyperdriveBinding(fixture)) { + env[name] = fixture + continue + } + + const connectionString = getHyperdriveConnectionString(name, binding, fixture) + if (connectionString) { + env[name] = createMockHyperdrive(connectionString) + continue + } + + missingFixtures.push({ + service: 'hyperdrive', + binding: name, + reason: `Hyperdrive binding "${name}" has no local connection string. Configure bindings.hyperdrive.${name}.localConnectionString or pass fixtures.hyperdrive.${name}.` + }) + } +} + +function addWorkerLoaderBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.workerLoaders ?? {})) { + env[name] = createMockWorkerLoader(fixtures.workerLoaders?.[name]) + } +} + +function addMTLSCertificateBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.mtlsCertificates ?? {})) { + env[name] = createMockMTLSCertificate(fixtures.mtlsCertificates?.[name]) + } +} + +function addDispatchNamespaceBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.dispatchNamespaces ?? {})) { + env[name] = createMockDispatchNamespace(fixtures.dispatchNamespaces?.[name]) + } +} + +function addWorkflowBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.workflows ?? {})) { + const fixture = fixtures.workflows?.[name] + env[name] = fixture && isWorkflowBinding(fixture) ? fixture : createMockWorkflow(fixture) + } +} + +function addPipelineBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.pipelines ?? {})) { + const fixture = fixtures.pipelines?.[name] + env[name] = isPipelineBinding(fixture) ? fixture : createMockPipeline() + } +} + +function addImagesBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.images ?? {})) { + const fixture = fixtures.images?.[name] + env[name] = isImagesBinding(fixture) ? fixture : createMockImagesBinding(fixture) + } +} + +function addMediaBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.media ?? {})) { + const fixture = fixtures.media?.[name] + env[name] = isMediaBinding(fixture) ? fixture : createMockMediaBinding(fixture) + } +} + +function addArtifactsBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const name of Object.keys(bindings?.artifacts ?? {})) { + const fixture = fixtures.artifacts?.[name] + env[name] = isArtifactsBinding(fixture) ? fixture : createMockArtifacts(fixture) + } +} + +function addSecretsStoreBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures, + localSecretValues: Record, + missingFixtures: OfflineMissingFixture[] +) { + for (const name of Object.keys(bindings?.secretsStore ?? {})) { + const value = fixtures.secretsStore?.[name] ?? localSecretValues[name] + if (value === undefined) { + missingFixtures.push({ + service: 'secretsStore', + binding: name, + reason: `Secrets Store values are not present in fixtures or the local secret store; pass fixtures.secretsStore.${name} or run devflare secrets --local.` + }) + env[name] = createMissingSecret(name) + } else { + env[name] = createMockSecretsStoreSecret(value) + } + } +} + +function addAISearchBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const [name, binding] of Object.entries(bindings?.aiSearch ?? {})) { + const fixture = fixtures.aiSearch?.[name] + env[name] = isAISearchInstance(fixture) + ? fixture + : createMockAISearchInstance({ + id: binding.instanceName, + ...fixture + }) + } +} + +function addAISearchNamespaceBindings( + env: Record, + bindings: OfflineConfig['bindings'], + fixtures: OfflineBindingFixtures +) { + for (const [name, binding] of Object.entries(bindings?.aiSearchNamespaces ?? {})) { + const fixture = fixtures.aiSearchNamespaces?.[name] + env[name] = isAISearchNamespace(fixture) + ? fixture + : createMockAISearchNamespace({ + namespace: binding.namespace, + ...fixture + }) + } +} + +function addRemoteBoundaries( + remoteBoundaries: OfflineRemoteBoundary[], + bindings: OfflineConfig['bindings'] +) { + if (bindings?.ai) { + addBoundary( + remoteBoundaries, + 'ai', + bindings.ai.binding || 'AI', + 'Workers AI inference is not available in offline local simulations.' + ) + } + + for (const name of Object.keys(bindings?.vectorize ?? {})) { + addBoundary( + remoteBoundaries, + 'vectorize', + name, + 'Vectorize has no offline local simulation in Cloudflare local development.' + ) + } +} + +/** + * Builds a deterministic, pure-test env object from Devflare config. + */ +export function createOfflineBindings( + config: OfflineConfig, + fixtures: OfflineBindingFixtures = {}, + options: OfflineBindingOptions = {} +): OfflineBindingsResult { + const env: Record = {} + const remoteBoundaries: OfflineRemoteBoundary[] = [] + const missingFixtures: OfflineMissingFixture[] = [] + const bindings = config.bindings + const localSecretValues = options.cwd && options.useLocalSecrets !== false + ? resolveLocalSecretValuesForBindings(config, options.cwd) + : {} + + addStaticBindings(env, config) + addRateLimitBindings(env, bindings) + addVersionMetadataBinding(env, bindings) + addHyperdriveBindings(env, bindings, fixtures, missingFixtures) + addWorkerLoaderBindings(env, bindings, fixtures) + addMTLSCertificateBindings(env, bindings, fixtures) + addDispatchNamespaceBindings(env, bindings, fixtures) + addWorkflowBindings(env, bindings, fixtures) + addPipelineBindings(env, bindings, fixtures) + addImagesBindings(env, bindings, fixtures) + addMediaBindings(env, bindings, fixtures) + addArtifactsBindings(env, bindings, fixtures) + addSecretsStoreBindings(env, bindings, fixtures, localSecretValues, missingFixtures) + addAISearchBindings(env, bindings, fixtures) + addAISearchNamespaceBindings(env, bindings, fixtures) + addRemoteBoundaries(remoteBoundaries, bindings) + + if (fixtures.custom) { + Object.assign(env, fixtures.custom) + } + + return { + env, + support: getOfflineSupportMatrix(), + remoteBoundaries, + missingFixtures + } +} + +/** + * Convenience wrapper for callers that only need the derived env object. + */ +export function createOfflineEnv( + config: OfflineConfig, + fixtures: OfflineBindingFixtures = {}, + options: OfflineBindingOptions = {} +): Record { + return createOfflineBindings(config, fixtures, options).env +} diff --git a/packages/devflare/src/test/queue.ts b/packages/devflare/src/test/queue.ts new file mode 100644 index 0000000..6e0b722 --- /dev/null +++ b/packages/devflare/src/test/queue.ts @@ -0,0 +1,291 @@ +// ============================================================================= +// Queue Test Helper โ€” Trigger queue handlers in Bun tests +// ============================================================================= +// Usage: +// import { cf } from 'devflare/test' +// +// // Trigger the queue handler with messages +// await cf.queue.trigger([ +// { id: 'msg-1', body: { type: 'process', data: { x: 1 } } }, +// { id: 'msg-2', body: { type: 'cleanup', data: {} } } +// ]) +// +// // Or use the convenience method for single messages +// await cf.queue.send({ type: 'process', data: { x: 1 } }) +// ============================================================================= + +import type { MessageBatch, Message } from '@cloudflare/workers-types' +import { join } from 'path' +import { createQueueEvent, runWithEventContext } from '../runtime' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface QueueMessageOptions { + /** Unique message ID (auto-generated if not provided) */ + id?: string + /** Message body */ + body: T + /** Timestamp when message was enqueued (defaults to now) */ + timestamp?: Date + /** Number of times this message has been retried */ + attempts?: number +} + +export interface QueueTriggerResult { + /** Messages that were acknowledged */ + acked: string[] + /** Messages that were retried */ + retried: string[] + /** Messages that were explicitly failed with noRetry */ + failed: string[] + /** Total messages processed */ + total: number +} + +// ----------------------------------------------------------------------------- +// Global State (set by createTestContext) +// ----------------------------------------------------------------------------- + +let queueHandlerPath: string | null = null +let configDir: string | null = null +let testEnvGetter: (() => Record) | null = null + +// ----------------------------------------------------------------------------- +// Configuration (called by createTestContext) +// ----------------------------------------------------------------------------- + +/** + * Configure the queue test helper + * @internal Called by createTestContext to set up handler path and env + */ +export function configureQueue(options: { + handlerPath: string | null + configDir: string + getEnv: () => Record +}): void { + queueHandlerPath = options.handlerPath + configDir = options.configDir + testEnvGetter = options.getEnv +} + +/** + * Reset queue helper state + * @internal Called when test context is disposed + */ +export function resetQueueState(): void { + queueHandlerPath = null + configDir = null + testEnvGetter = null +} + +// ----------------------------------------------------------------------------- +// Message Builder +// ----------------------------------------------------------------------------- + +const EMPTY_QUEUE_METADATA: MessageBatchMetadata = { + metrics: { + backlogCount: 0, + backlogBytes: 0 + } +} + +/** + * Create a mock Message object that tracks ack/retry/noRetry calls + */ +function createMessage(options: QueueMessageOptions): Message & { + _state: 'pending' | 'acked' | 'retried' | 'failed' +} { + const id = options.id ?? crypto.randomUUID() + const timestamp = options.timestamp ?? new Date() + const attempts = options.attempts ?? 1 + + let state: 'pending' | 'acked' | 'retried' | 'failed' = 'pending' + + return { + id, + timestamp, + body: options.body, + attempts, + ack() { + state = 'acked' + }, + retry(opts?: { delaySeconds?: number }) { + state = 'retried' + }, + retryAll() { + state = 'retried' + }, + // Undocumented but exists โ€” marks as failed with no retry + get _state() { + return state + } + } as Message & { _state: 'pending' | 'acked' | 'retried' | 'failed' } +} + +/** + * Create a mock MessageBatch from an array of messages + */ +function createMessageBatch(messages: Array & { _state: string }>): MessageBatch { + return { + queue: 'test-queue', + metadata: EMPTY_QUEUE_METADATA, + messages, + ackAll() { + for (const msg of messages) { + msg.ack() + } + }, + retryAll() { + for (const msg of messages) { + msg.retry() + } + } + } +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Trigger the queue handler with a batch of messages. + * This directly invokes the queue handler function from your config. + * + * @param messages - Array of message options or bodies + * @returns Result object with acked/retried/failed message IDs + * + * @example + * ```ts + * // Trigger with message bodies (IDs auto-generated) + * const result = await cf.queue.trigger([ + * { type: 'process', data: { x: 1 } }, + * { type: 'cleanup', data: {} } + * ]) + * + * // Trigger with full message options + * const result = await cf.queue.trigger([ + * { id: 'msg-1', body: { type: 'process' }, attempts: 2 } + * ]) + * ``` + */ +async function trigger( + messages: Array | T> +): Promise { + if (!queueHandlerPath) { + throw new Error( + 'Queue handler not configured. Make sure your devflare.config.ts has files.queue set, ' + + 'and the file exists at the specified path (default: src/queue.ts)' + ) + } + + if (!configDir || !testEnvGetter) { + throw new Error( + 'Queue helper not initialized. Call createTestContext() before using cf.queue.trigger()' + ) + } + + // Import the queue handler + const absolutePath = join(configDir, queueHandlerPath) + const handlerModule = await import(absolutePath) + + // Get the default export (the queue handler function) + const queueHandler = handlerModule.default ?? handlerModule.queue + if (typeof queueHandler !== 'function') { + throw new Error( + `Queue handler at "${queueHandlerPath}" must export a default function or named "queue" export.\n` + + + `Expected: export async function queue(event) { ... }` + ) + } + + // Normalize messages to QueueMessageOptions + const normalizedMessages = messages.map((msg) => { + if (typeof msg === 'object' && msg !== null && 'body' in msg) { + return msg as QueueMessageOptions + } + return { body: msg as T } + }) + + // Create mock messages with state tracking + const mockMessages = normalizedMessages.map((opts) => createMessage(opts)) + + // Create the batch + const batch = createMessageBatch(mockMessages) + + // Create execution context + const waitUntilPromises: Promise[] = [] + const ctx: ExecutionContext = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { }, + props: {} + } + + // Get the test env + const env = testEnvGetter() + const queueEvent = createQueueEvent(batch, env, ctx) + + // Call the handler + await runWithEventContext( + queueEvent, + () => queueHandler(queueEvent) + ) + + // Wait for all waitUntil promises + await Promise.all(waitUntilPromises) + + // Collect results + const acked: string[] = [] + const retried: string[] = [] + const failed: string[] = [] + + for (const msg of mockMessages) { + switch (msg._state) { + case 'acked': + acked.push(msg.id) + break + case 'retried': + retried.push(msg.id) + break + case 'failed': + failed.push(msg.id) + break + // 'pending' means neither ack nor retry was called + } + } + + return { + acked, + retried, + failed, + total: mockMessages.length + } +} + +/** + * Convenience method to trigger the queue handler with a single message. + * + * @param message - Message body or options + * @returns Result object + * + * @example + * ```ts + * await cf.queue.send({ type: 'process', data: { x: 1 } }) + * ``` + */ +async function send( + message: QueueMessageOptions | T +): Promise { + return trigger([message]) +} + +// ----------------------------------------------------------------------------- +// Export +// ----------------------------------------------------------------------------- + +export const queue = { + trigger, + send +} diff --git a/packages/devflare/src/test/remote-ai.ts b/packages/devflare/src/test/remote-ai.ts new file mode 100644 index 0000000..aa51ffb --- /dev/null +++ b/packages/devflare/src/test/remote-ai.ts @@ -0,0 +1,129 @@ +// ============================================================================= +// Remote AI Binding โ€” REST API implementation +// ============================================================================= +// Provides an AI binding that calls Cloudflare's REST API directly. +// This allows testing AI functionality without a running dev server. +// ============================================================================= + +import { createRemoteCloudflareClient } from './remote-cloudflare' + +// ----------------------------------------------------------------------------- +// Remote AI Binding +// ----------------------------------------------------------------------------- + +interface RemoteAIWithGatewayLog { + aiGatewayLogId: string | null +} + +function encodePathSegment(value: string): string { + return encodeURIComponent(value) +} + +function applyExtraHeaders(headers: Headers, extraHeaders?: object): void { + if (!extraHeaders) { + return + } + + for (const [key, value] of Object.entries(extraHeaders)) { + headers.set(key, String(value)) + } +} + +function createRemoteAIGateway( + cloudflare: ReturnType, + gatewayId: string, + owner: RemoteAIWithGatewayLog +): AiGateway { + const encodedGatewayId = encodePathSegment(gatewayId) + + const gateway = { + async patchLog(logId: string, data: AiGatewayPatchLog): Promise { + await cloudflare.jsonRequest({ + method: 'PATCH', + path: `/ai-gateway/gateways/${encodedGatewayId}/logs/${encodePathSegment(logId)}`, + serviceLabel: 'AI Gateway', + body: JSON.stringify(data) + }) + }, + + async getLog(logId: string): Promise { + return cloudflare.jsonRequest({ + method: 'GET', + path: `/ai-gateway/gateways/${encodedGatewayId}/logs/${encodePathSegment(logId)}`, + serviceLabel: 'AI Gateway' + }) + }, + + async getUrl(provider?: AIGatewayProviders | string): Promise { + const accountId = await cloudflare.getAccountId() + const baseUrl = `https://gateway.ai.cloudflare.com/v1/${encodePathSegment(accountId)}/${encodedGatewayId}` + return provider ? `${baseUrl}/${encodePathSegment(provider)}` : `${baseUrl}/` + }, + + async run( + data: AIGatewayUniversalRequest | AIGatewayUniversalRequest[], + options?: { + gateway?: UniversalGatewayOptions + extraHeaders?: object + signal?: AbortSignal + } + ): Promise { + const [url, token] = await Promise.all([gateway.getUrl(), cloudflare.getToken()]) + const headers = new Headers({ + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }) + applyExtraHeaders(headers, options?.extraHeaders) + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(data), + signal: options?.signal + }) + + const logId = response.headers.get('cf-aig-log-id') ?? response.headers.get('cf-ai-gateway-log-id') + if (logId) { + owner.aiGatewayLogId = logId + } + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`AI Gateway API error (${response.status}): ${errorText}`) + } + + return response + } + } + + return gateway as AiGateway +} + +/** + * Creates a remote AI binding that calls Cloudflare's REST API. + * Matches the Workers AI binding interface. + */ +export function createRemoteAI(accountId?: string): Ai { + const cloudflare = createRemoteCloudflareClient(accountId) + + // Create an object that implements the Ai interface via REST API + // Use type assertion since we're implementing via REST, not the native binding + const ai = { + aiGatewayLogId: null as string | null, + + async run(model: string, inputs: unknown): Promise { + return cloudflare.jsonRequest({ + method: 'POST', + path: `/ai/run/${model}`, + serviceLabel: 'AI', + body: JSON.stringify(inputs) + }) + }, + + gateway(gatewayId: string): AiGateway { + return createRemoteAIGateway(cloudflare, gatewayId, ai) + } + } + + return ai as unknown as Ai +} diff --git a/packages/devflare/src/test/remote-cloudflare.ts b/packages/devflare/src/test/remote-cloudflare.ts new file mode 100644 index 0000000..fda8db4 --- /dev/null +++ b/packages/devflare/src/test/remote-cloudflare.ts @@ -0,0 +1,82 @@ +import { getPrimaryAccount } from '../cloudflare/account' +import { getApiToken } from '../cloudflare/auth' +import { getEffectiveAccountId } from '../cloudflare/preferences' + +interface CloudflareApiEnvelope { + success: boolean + result: T + errors?: Array<{ message: string }> +} + +export interface RemoteCloudflareJsonRequestOptions { + method: string + path: string + serviceLabel: string + body?: string + contentType?: string +} + +export function createRemoteCloudflareClient(accountId?: string): { + getAccountId: () => Promise + getToken: () => Promise + jsonRequest: (options: RemoteCloudflareJsonRequestOptions) => Promise +} { + let resolvedAccountId: string | null = null + + async function getAccountId(): Promise { + if (accountId) { + return accountId + } + if (resolvedAccountId) { + return resolvedAccountId + } + + const primary = await getPrimaryAccount() + if (!primary) { + throw new Error('No Cloudflare account found. Run: bunx wrangler login') + } + + const { accountId: effectiveId } = await getEffectiveAccountId(primary.id) + resolvedAccountId = effectiveId + return effectiveId + } + + async function getToken(): Promise { + const token = await getApiToken() + if (!token) { + throw new Error('Not authenticated. Run: bunx wrangler login') + } + return token + } + + async function jsonRequest(options: RemoteCloudflareJsonRequestOptions): Promise { + const [acctId, token] = await Promise.all([getAccountId(), getToken()]) + const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${acctId}${options.path}`, { + method: options.method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': options.contentType ?? 'application/json' + }, + body: options.body + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`${options.serviceLabel} API error (${response.status}): ${errorText}`) + } + + const result = await response.json() as CloudflareApiEnvelope + if (!result.success) { + const message = result.errors?.[0]?.message || `Unknown ${options.serviceLabel} error` + throw new Error(`${options.serviceLabel} API error: ${message}`) + } + + return result.result + } + + return { + getAccountId, + getToken, + jsonRequest + } +} diff --git a/packages/devflare/src/test/remote-vectorize.ts b/packages/devflare/src/test/remote-vectorize.ts new file mode 100644 index 0000000..e35af3e --- /dev/null +++ b/packages/devflare/src/test/remote-vectorize.ts @@ -0,0 +1,103 @@ +// ============================================================================= +// Remote Vectorize Binding โ€” REST API implementation +// ============================================================================= +// Provides a Vectorize binding that calls Cloudflare's REST API directly. +// This allows testing Vectorize functionality without a running dev server. +// ============================================================================= + +import { createRemoteCloudflareClient } from './remote-cloudflare' + +// ----------------------------------------------------------------------------- +// Remote Vectorize Binding +// ----------------------------------------------------------------------------- + +/** + * Creates a remote Vectorize binding that calls Cloudflare's REST API. + * Matches the Workers Vectorize binding interface. + */ +export function createRemoteVectorize(indexName: string, accountId?: string): VectorizeIndex { + const cloudflare = createRemoteCloudflareClient(accountId) + + async function apiRequest( + method: string, + endpoint: string, + body?: unknown + ): Promise { + return cloudflare.jsonRequest({ + method, + path: `/vectorize/v2/indexes/${indexName}${endpoint}`, + serviceLabel: 'Vectorize', + body: body ? JSON.stringify(body) : undefined + }) + } + + async function ndjsonRequest( + endpoint: string, + vectors: VectorizeVector[] + ): Promise { + // Vectorize uses NDJSON for insert/upsert + const ndjson = vectors.map((v) => JSON.stringify(v)).join('\n') + + return cloudflare.jsonRequest({ + method: 'POST', + path: `/vectorize/v2/indexes/${indexName}${endpoint}`, + serviceLabel: 'Vectorize', + contentType: 'application/x-ndjson', + body: ndjson + }) + } + + // Create an object that implements VectorizeIndex via REST API + // Use type assertions since we're implementing via REST, not the native binding + const vectorize = { + async describe(): Promise { + return apiRequest('GET', '') + }, + + async query( + vector: number[] | Float32Array | Float64Array, + options?: VectorizeQueryOptions + ): Promise { + const vectorArray = Array.isArray(vector) ? vector : Array.from(vector) + + return apiRequest('POST', '/query', { + vector: vectorArray, + topK: options?.topK ?? 10, + returnValues: options?.returnValues ?? false, + returnMetadata: options?.returnMetadata ?? 'none', + namespace: options?.namespace, + filter: options?.filter + }) + }, + + async insert(vectors: VectorizeVector[]): Promise { + const result = await ndjsonRequest<{ mutationId: string; count: number; ids?: string[] }>('/insert', vectors) + return { + count: result.count, + ids: result.ids || vectors.map((v) => v.id) + } + }, + + async upsert(vectors: VectorizeVector[]): Promise { + const result = await ndjsonRequest<{ mutationId: string; count: number; ids?: string[] }>('/upsert', vectors) + return { + count: result.count, + ids: result.ids || vectors.map((v) => v.id) + } + }, + + async deleteByIds(ids: string[]): Promise { + const result = await apiRequest<{ mutationId: string; count: number }>('POST', '/delete-by-ids', { ids }) + return { + count: result.count, + ids + } + }, + + async getByIds(ids: string[]): Promise { + return apiRequest('POST', '/get-by-ids', { ids }) + } + } + + return vectorize as unknown as VectorizeIndex +} diff --git a/packages/devflare/src/test/resolve-service-bindings.ts b/packages/devflare/src/test/resolve-service-bindings.ts new file mode 100644 index 0000000..67d9e7f --- /dev/null +++ b/packages/devflare/src/test/resolve-service-bindings.ts @@ -0,0 +1,906 @@ +// ============================================================================= +// Service Binding Resolution โ€” Resolves service bindings for multi-worker tests +// ============================================================================= +// When createTestContext detects service bindings with __ref metadata, +// this module resolves the referenced worker configs and bundles their scripts. +// ============================================================================= + +import { dirname, join, resolve } from 'path' +import { existsSync, readFileSync } from 'fs' +import { + getLocalD1DatabaseIdentifier, + getLocalKVNamespaceIdentifier, + normalizeDOBinding, + configSchema, + type DevflareConfig, + type DurableObjectBinding, + type DOBindingRef +} from '../config' +import type { RefResult, WorkerBinding } from '../config/ref' +import { transformWorkerEntrypoint } from '../transform/worker-entrypoint' +import { discoverEntrypointsSync } from '../utils/entrypoint-discovery' +import { findDurableObjectClasses } from '../transform/durable-object' +import { findFilesSync, DEFAULT_DO_PATTERN } from '../utils/glob' +import { resolvePackageSpecifier } from '../utils/resolve-package' +import { + buildAiSearchInstancesConfig, + buildAiSearchNamespacesConfig, + buildArtifactsConfig, + buildDispatchNamespacesConfig, + buildHyperdrivesConfig, + buildMediaConfig, + buildMtlsCertificatesConfig, + buildPipelinesConfig, + buildQueueConsumers, + buildQueueProducers, + buildRateLimitsConfig, + buildSecretsStoreConfig, + buildSendEmailConfig, + buildVersionMetadataConfig, + buildWorkerLoadersConfig, + buildWorkflowsConfig +} from '../dev-server/miniflare-bindings' + +// ----------------------------------------------------------------------------- +// Bun Runtime Detection +// ----------------------------------------------------------------------------- + +function getBunRuntime(): { + build: (options: { + entrypoints: string[] + target: string + format: string + minify: boolean + external?: string[] + }) => Promise<{ + success: boolean + logs: string[] + outputs: Array<{ text: () => Promise }> + }> +} | undefined { + const g = globalThis as { Bun?: unknown } + if (typeof g.Bun === 'object' && g.Bun !== null) { + return g.Bun as ReturnType + } + return undefined +} + +// Entrypoint discovery imported from shared utils: discoverEntrypointsSync + +// ----------------------------------------------------------------------------- +// DO File Discovery +// ----------------------------------------------------------------------------- + +/** + * Discover DO files matching do.*.ts/js pattern recursively in a directory + * Uses the same glob pattern as the rest of the codebase for consistency. + * Returns map of className -> filePath + */ +function discoverDOFilesSync(dir: string, pattern: string = DEFAULT_DO_PATTERN): Map { + const classToPath = new Map() + + try { + const files = findFilesSync(pattern, { cwd: dir }) + + for (const filePath of files) { + try { + const code = readFileSync(filePath, 'utf-8') + const classNames = findDurableObjectClasses(code) + + for (const className of classNames) { + if (!classToPath.has(className)) { + classToPath.set(className, filePath) + } + } + } catch { + // Skip unreadable files + } + } + } catch { + // Glob failed โ€” return empty map + } + + return classToPath +} + +// ----------------------------------------------------------------------------- +// Bundle Cache +// ----------------------------------------------------------------------------- + +/** + * Cache for bundled worker scripts to avoid re-bundling in repeated test runs. + * Key: entryPath + entrypoint, Value: bundled script code + */ +const bundleCache = new Map() + +/** + * Clear the bundle cache (useful between test suites) + */ +export function clearBundleCache(): void { + bundleCache.clear() +} + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * Resolved worker configuration for Miniflare + */ +export interface ResolvedWorker { + /** Worker name */ + name: string + /** Bundled script code */ + script: string + /** Whether the script uses ES modules */ + modules: boolean + /** Compatibility date */ + compatibilityDate: string + compatibilityFlags?: string[] + bindings?: Record + kvNamespaces?: Record + r2Buckets?: Record + d1Databases?: Record + queueProducers?: Record + queueConsumers?: Record> + /** Service bindings to other workers */ + serviceBindings?: Record + /** Durable Object bindings for classes hosted by this worker or another script */ + durableObjects?: Record + ratelimits?: Record + versionMetadata?: string + workerLoaders?: Record> + mtlsCertificates?: Record + dispatchNamespaces?: Record + workflows?: Record + pipelines?: Record + hyperdrives?: Record + media?: { binding: string } + artifacts?: Record + aiSearchNamespaces?: Record + aiSearchInstances?: Record + secretsStoreSecrets?: Record + email?: { + send_email: Array<{ + name: string + destination_address?: string + allowed_destination_addresses?: string[] + allowed_sender_addresses?: string[] + }> + } +} + +/** + * Result of resolving service bindings + */ +export interface ServiceBindingResolution { + /** All resolved workers (including primary) */ + workers: ResolvedWorker[] + /** Service bindings for the primary worker */ + primaryServiceBindings: Record +} + +function findDefaultServiceWorkerEntrypoint(refConfigDir: string): string | null { + for (const candidate of ['src/worker.ts', 'src/worker.js']) { + const absolutePath = resolve(refConfigDir, candidate) + if (existsSync(absolutePath)) { + return absolutePath + } + } + + return null +} + +function buildRawServiceBindings( + services: NonNullable['services']> | undefined +): Record | undefined { + if (!services || Object.keys(services).length === 0) { + return undefined + } + + return Object.fromEntries( + Object.entries(services).map(([bindingName, binding]) => [ + bindingName, + { + name: binding.service, + ...(binding.entrypoint && { entrypoint: binding.entrypoint }) + } + ]) + ) +} + +function buildReferencedWorkerRuntimeConfig(config: DevflareConfig): Partial { + const bindings = (config.bindings ?? {}) as NonNullable + const queueProducers = buildQueueProducers(bindings) + const queueConsumers = buildQueueConsumers(bindings) + const rateLimits = buildRateLimitsConfig(bindings) + const versionMetadata = buildVersionMetadataConfig(bindings) + const workerLoaders = buildWorkerLoadersConfig(bindings) + const mtlsCertificates = buildMtlsCertificatesConfig(bindings) + const dispatchNamespaces = buildDispatchNamespacesConfig(bindings) + const workflows = buildWorkflowsConfig(bindings) + const pipelines = buildPipelinesConfig(bindings) + const hyperdrives = buildHyperdrivesConfig(bindings) + const media = buildMediaConfig(bindings) + const artifacts = buildArtifactsConfig(bindings) + const aiSearchNamespaces = buildAiSearchNamespacesConfig(bindings) + const aiSearchInstances = buildAiSearchInstancesConfig(bindings) + const secretsStoreSecrets = buildSecretsStoreConfig(bindings, config.secretsStoreId) + const email = buildSendEmailConfig(bindings) + const serviceBindings = buildRawServiceBindings(bindings.services) + + return { + ...(config.compatibilityFlags && { compatibilityFlags: config.compatibilityFlags }), + ...(config.vars && { bindings: config.vars }), + ...(bindings.kv && { + kvNamespaces: Object.fromEntries( + Object.entries(bindings.kv).map(([bindingName, bindingConfig]) => [ + bindingName, + getLocalKVNamespaceIdentifier(bindingConfig) + ]) + ) + }), + ...(bindings.r2 && { r2Buckets: bindings.r2 }), + ...(bindings.d1 && { + d1Databases: Object.fromEntries( + Object.entries(bindings.d1).map(([bindingName, bindingConfig]) => [ + bindingName, + getLocalD1DatabaseIdentifier(bindingConfig) + ]) + ) + }), + ...(queueProducers && { queueProducers }), + ...(queueConsumers && { queueConsumers }), + ...(rateLimits && { ratelimits: rateLimits }), + ...(versionMetadata && { versionMetadata }), + ...(workerLoaders && { workerLoaders }), + ...(mtlsCertificates && { mtlsCertificates }), + ...(dispatchNamespaces && { dispatchNamespaces }), + ...(workflows && { workflows }), + ...(pipelines && { pipelines }), + ...(hyperdrives && { hyperdrives }), + ...(media && { media }), + ...(artifacts && { artifacts }), + ...(aiSearchNamespaces && { aiSearchNamespaces }), + ...(aiSearchInstances && { aiSearchInstances }), + ...(secretsStoreSecrets && { secretsStoreSecrets }), + ...(email && { email }), + ...(serviceBindings && { serviceBindings }) + } +} + +function normalizeReferencedConfig(config: RefResult['config']): DevflareConfig { + return configSchema.parse(config) +} + +function resolveReferencedConfigDir(ref: RefResult, parentConfigDir: string): string | null { + const configPath = ref.configPath + if (!configPath || configPath === '') { + return null + } + + return dirname(resolvePackageSpecifier(configPath, parentConfigDir)) +} + +interface ReferencedDurableObjectResolution { + workers: ResolvedWorker[] + bindings: Record +} + +async function resolveReferencedLocalDurableObjects( + config: DevflareConfig, + configDir: string, + workerName: string, + serviceBindings: Record = {} +): Promise { + const doPattern = config.files?.durableObjects + const dosConfig = config.bindings?.durableObjects + + if (typeof doPattern !== 'string' || !dosConfig || Object.keys(dosConfig).length === 0) { + return { workers: [], bindings: {} } + } + + const discoveredDOs = discoverDOFilesSync(configDir, doPattern) + const doClasses: Array<{ bindingName: string; className: string; scriptPath: string }> = [] + + for (const [bindingName, rawDoConfig] of Object.entries(dosConfig)) { + const doConfig = normalizeDOBinding(rawDoConfig as DurableObjectBinding) + if (doConfig.kind !== 'local') { + continue + } + + const scriptPath = discoveredDOs.get(doConfig.className) + if (!scriptPath) { + console.warn(`[devflare] DO "${bindingName}" (class: ${doConfig.className}) not found in files.durableObjects for "${workerName}"`) + continue + } + + doClasses.push({ bindingName, className: doConfig.className, scriptPath }) + } + + if (doClasses.length === 0) { + return { workers: [], bindings: {} } + } + + const doWorkerName = `${workerName}-durable-objects` + const script = await bundleDOClasses(doClasses, doWorkerName) + if (!script) { + return { workers: [], bindings: {} } + } + + const durableObjects = Object.fromEntries( + doClasses.map((do_) => [do_.bindingName, do_.className]) + ) + const runtimeConfig = buildReferencedWorkerRuntimeConfig(config) + const doRuntimeConfig = { ...runtimeConfig } + delete doRuntimeConfig.queueConsumers + const mergedServiceBindings = { + ...(runtimeConfig.serviceBindings ?? {}), + ...serviceBindings + } + + return { + workers: [{ + name: doWorkerName, + script, + modules: true, + compatibilityDate: config.compatibilityDate, + ...doRuntimeConfig, + ...(Object.keys(mergedServiceBindings).length > 0 && { + serviceBindings: mergedServiceBindings + }), + durableObjects + }], + bindings: Object.fromEntries( + doClasses.map((do_) => [ + do_.bindingName, + { + className: do_.className, + scriptName: doWorkerName + } + ]) + ) + } +} + +// ----------------------------------------------------------------------------- +// Main API +// ----------------------------------------------------------------------------- + +/** + * Check if a config has service bindings that need multi-worker setup + */ +export function hasServiceBindings(config: DevflareConfig): boolean { + const services = config.bindings?.services + if (!services) return false + return Object.keys(services).length > 0 +} + +/** + * Resolve service bindings from a config + * Returns the workers array and service bindings for Miniflare setup + */ +export async function resolveServiceBindings( + config: DevflareConfig, + configDir: string, + seenWorkers: Set = new Set() +): Promise { + const services = config.bindings?.services + if (!services) { + return { workers: [], primaryServiceBindings: {} } + } + + // Track resolved workers by name to avoid duplicates + const workersByName = new Map() + const primaryServiceBindings: Record = {} + + for (const [bindingName, binding] of Object.entries(services)) { + const workerBinding = binding as WorkerBinding + const ref = workerBinding.__ref + + if (ref) { + // Resolve the ref if it has an __import function (new API) + if ('__import' in ref && typeof ref.__import === 'function') { + await ref.resolve() + } + + const workerName = ref.name + const entrypoint = workerBinding.entrypoint + + // Only resolve worker once per unique worker name + // bundleAllEntrypoints will include the default worker entrypoint plus + // all named entrypoints discovered from files.entrypoints. + if (!workersByName.has(workerName) && !seenWorkers.has(workerName)) { + const refConfig = normalizeReferencedConfig(ref.config) + const worker = await resolveRefWorker(ref, entrypoint, configDir, refConfig) + if (worker) { + const refConfigDir = resolveReferencedConfigDir(ref, configDir) + if (ref.config && refConfigDir) { + const nested = await resolveServiceBindings( + refConfig, + refConfigDir, + new Set([...seenWorkers, workerName]) + ) + worker.serviceBindings = { + ...(worker.serviceBindings ?? {}), + ...nested.primaryServiceBindings + } + for (const nestedWorker of nested.workers) { + if (!workersByName.has(nestedWorker.name)) { + workersByName.set(nestedWorker.name, nestedWorker) + } + } + + const localDOs = await resolveReferencedLocalDurableObjects( + refConfig, + refConfigDir, + workerName, + nested.primaryServiceBindings + ) + worker.durableObjects = { + ...(worker.durableObjects ?? {}), + ...localDOs.bindings + } + for (const doWorker of localDOs.workers) { + if (!workersByName.has(doWorker.name)) { + workersByName.set(doWorker.name, doWorker) + } + } + } + workersByName.set(workerName, worker) + } + } + + primaryServiceBindings[bindingName] = { + name: workerName, + ...(entrypoint && { entrypoint }) + } + } else { + // No ref, just use the service binding as-is + // This means the worker must be set up separately + primaryServiceBindings[bindingName] = { + name: workerBinding.service, + ...(workerBinding.entrypoint && { entrypoint: workerBinding.entrypoint }) + } + } + } + + return { + workers: [...workersByName.values()], + primaryServiceBindings + } +} + +/** + * Resolve a referenced worker config to a bundled script. + * Bundles the default `src/worker.{ts,js}` RPC surface plus any named + * entrypoints discovered from `files.entrypoints` into a single script. + */ +async function resolveRefWorker( + ref: RefResult, + _entrypoint: string | undefined, // Ignored - we bundle all entrypoints + parentConfigDir: string, + resolvedConfig?: DevflareConfig +): Promise { + const config = resolvedConfig ?? normalizeReferencedConfig(ref.config) + if (!config) return null + + const refConfigDir = resolveReferencedConfigDir(ref, parentConfigDir) + if (!refConfigDir) { + console.warn(`[devflare] Cannot resolve worker "${ref.name}" - configPath not available`) + return null + } + + // Collect all entrypoints to bundle + const entrypoints: Array<{ path: string; className: string; isWorkerTs: boolean }> = [] + + // 1. Default worker RPC surface from src/worker.{ts,js} + const workerEntrypointPath = findDefaultServiceWorkerEntrypoint(refConfigDir) + + if (workerEntrypointPath) { + entrypoints.push({ + path: workerEntrypointPath, + className: 'Worker', + isWorkerTs: true + }) + } + + // 2. Auto-discover named entrypoints from files.entrypoints (or the default pattern) + if (config.files?.entrypoints !== false) { + const discoveredEntrypoints = discoverEntrypointsSync( + refConfigDir, + typeof config.files?.entrypoints === 'string' + ? config.files.entrypoints + : undefined + ) + + for (const ep of discoveredEntrypoints) { + entrypoints.push({ + path: ep.filePath, + className: ep.className, + isWorkerTs: false // files.entrypoints files already export WorkerEntrypoint classes + }) + } + } + + if (entrypoints.length === 0) { + console.warn(`[devflare] Worker "${ref.name}" has no entry points`) + return null + } + + // Bundle all entrypoints into a single script + const script = await bundleAllEntrypoints(entrypoints, ref.name) + if (!script) return null + + return { + name: ref.name, + script, + modules: true, + compatibilityDate: config.compatibilityDate ?? '2025-01-01', + ...buildReferencedWorkerRuntimeConfig(config) + } +} + +/** + * Bundle multiple entrypoints into a single worker script + */ +async function bundleAllEntrypoints( + entrypoints: Array<{ path: string; className: string; isWorkerTs: boolean }>, + workerName: string +): Promise { + // Check cache first (use all paths as cache key) + const cacheKey = entrypoints.map((ep) => `${ep.path}::${ep.className}`).join('|') + const cached = bundleCache.get(cacheKey) + if (cached) { + return cached + } + + const bun = getBunRuntime() + if (!bun) { + console.warn('[devflare] Bun runtime required for bundling worker scripts') + return null + } + + try { + const { readFileSync, writeFileSync, mkdirSync, unlinkSync } = await import('fs') + + // Create a virtual entry file that re-exports all entrypoints + const imports: string[] = [] + const exports: string[] = [] + let defaultExportClass: string | null = null + + for (let i = 0; i < entrypoints.length; i++) { + const ep = entrypoints[i] + const sourceCode = readFileSync(ep.path, 'utf-8') + + if (ep.isWorkerTs) { + // Transform worker.ts to WorkerEntrypoint class + const result = transformWorkerEntrypoint(sourceCode, ep.path, { + className: ep.className, + injectContext: false + }) + + if (result) { + // Write transformed code to temp file + const tempDir = join(dirname(ep.path), '.devflare') + mkdirSync(tempDir, { recursive: true }) + const tempPath = join(tempDir, `__${ep.className}_${i}.ts`) + writeFileSync(tempPath, result.code) + + imports.push(`import { ${ep.className} } from '${tempPath.replace(/\\/g, '/')}'`) + exports.push(ep.className) + + // The default worker.ts becomes the default export + if (!defaultExportClass) { + defaultExportClass = ep.className + } + } + } else { + // ep.*.ts already exports WorkerEntrypoint class - import directly + imports.push(`import { ${ep.className} } from '${ep.path.replace(/\\/g, '/')}'`) + exports.push(ep.className) + } + } + + // Create the unified entry file + // Include default export for the Worker class (used when no entrypoint is specified) + const defaultExport = defaultExportClass + ? `\nexport default ${defaultExportClass}` + : '' + + const entryCode = ` +${imports.join('\n')} +export { ${exports.join(', ')} }${defaultExport} +` + + // Write entry file + const tempDir = join(dirname(entrypoints[0].path), '.devflare') + mkdirSync(tempDir, { recursive: true }) + const entryPath = join(tempDir, `__entry_${workerName}.ts`) + writeFileSync(entryPath, entryCode) + + try { + const result = await bun.build({ + entrypoints: [entryPath], + target: 'browser', + format: 'esm', + minify: false, + external: ['cloudflare:workers', 'cloudflare:*'] + }) + + if (!result.success) { + console.warn(`[devflare] Failed to bundle worker "${workerName}": ${result.logs.join('\n')}`) + return null + } + + const bundledCode = await result.outputs[0].text() + + // Cache the result + bundleCache.set(cacheKey, bundledCode) + + return bundledCode + } finally { + // Clean up temp files + try { + unlinkSync(entryPath) + } catch { + // Ignore cleanup errors + } + } + } catch (error) { + console.warn(`[devflare] Error bundling worker "${workerName}":`, error) + return null + } +} + +// ----------------------------------------------------------------------------- +// Cross-Worker DO Binding Resolution +// ----------------------------------------------------------------------------- + +/** + * Result of resolving cross-worker DO bindings + */ +export interface DOBindingResolution { + /** Workers that host cross-worker DOs */ + workers: ResolvedWorker[] + /** DO bindings for the primary worker (pointing to cross-worker DO hosting workers) */ + crossWorkerDOBindings: Record +} + +/** + * Check if a config has cross-worker DO bindings + */ +export function hasCrossWorkerDOs(config: DevflareConfig): boolean { + const dos = config.bindings?.durableObjects + if (!dos) return false + for (const doConfig of Object.values(dos)) { + const normalized = normalizeDOBinding(doConfig) + if (normalized.__ref) return true + } + return false +} + +/** + * Resolve cross-worker DO bindings + * Returns workers to set up and DO bindings for the primary worker + */ +export async function resolveDOBindings( + config: DevflareConfig, + configDir: string +): Promise { + const dos = config.bindings?.durableObjects + if (!dos) { + return { workers: [], crossWorkerDOBindings: {} } + } + + const workersByName = new Map() + const crossWorkerDOBindings: Record = {} + + for (const [bindingName, rawDoConfig] of Object.entries(dos)) { + // Check for __ref first (before normalizing) to detect cross-worker DOs + const hasRef = typeof rawDoConfig === 'object' && '__ref' in rawDoConfig + + if (!hasRef) { + // Local DO, skip (handled by regular DO bundling) + continue + } + + const ref = (rawDoConfig as DOBindingRef).__ref! + + // Resolve the ref BEFORE reading className/scriptName + if ('__import' in ref && typeof ref.__import === 'function') { + await ref.resolve() + } + + // Now normalize after resolution - className will be correct + const doConfig = normalizeDOBinding(rawDoConfig) + const workerName = ref.name + + // Bundle the worker if not already done + if (!workersByName.has(workerName)) { + const worker = await resolveDORefWorker(ref, configDir) + if (worker) { + workersByName.set(workerName, worker) + } + } + + // Add the cross-worker DO binding + crossWorkerDOBindings[bindingName] = { + className: doConfig.className, + scriptName: workerName + } + } + + return { + workers: [...workersByName.values()], + crossWorkerDOBindings + } +} + +/** + * Resolve a referenced worker for DO hosting + * Bundles the DO classes with RPC wrappers + */ +async function resolveDORefWorker( + ref: RefResult, + parentConfigDir: string +): Promise { + const config = ref.config + if (!config) return null + + const configPath = ref.configPath + if (!configPath || configPath === '') { + console.warn(`[devflare] Cannot resolve DO worker "${ref.name}" - configPath not available`) + return null + } + + // Resolve the config path (handles both relative paths and package specifiers) + const resolvedConfigPath = resolvePackageSpecifier(configPath, parentConfigDir) + const refConfigDir = dirname(resolvedConfigPath) + + // Get DO classes from the referenced config + const dosConfig = config.bindings?.durableObjects + if (!dosConfig || Object.keys(dosConfig).length === 0) { + console.warn(`[devflare] Referenced worker "${ref.name}" has no Durable Objects`) + return null + } + + // Auto-discover DO files in the referenced directory for classes without scriptName + const discoveredDOs = discoverDOFilesSync(refConfigDir) + + // Collect DO classes to bundle + const doClasses: Array<{ bindingName: string; className: string; scriptPath: string }> = [] + + for (const [bindingName, rawDoConfig] of Object.entries(dosConfig)) { + const doConfig = normalizeDOBinding(rawDoConfig as DurableObjectBinding) + const className = doConfig.className + const scriptName = doConfig.scriptName + + if (scriptName) { + // Explicit scriptName provided - resolve it + const scriptPath = resolve(refConfigDir, 'src', scriptName) + if (!existsSync(scriptPath)) { + // Try without src/ + const altPath = resolve(refConfigDir, scriptName) + if (!existsSync(altPath)) { + console.warn(`[devflare] DO script not found: ${scriptPath} or ${altPath}`) + continue + } + doClasses.push({ bindingName, className, scriptPath: altPath }) + } else { + doClasses.push({ bindingName, className, scriptPath }) + } + } else { + // No scriptName - try to auto-discover from do.*.ts files + const discoveredPath = discoveredDOs.get(className) + if (discoveredPath) { + doClasses.push({ bindingName, className, scriptPath: discoveredPath }) + } else { + console.warn(`[devflare] DO "${bindingName}" (class: ${className}) not found in do.*.ts files in "${ref.name}"`) + continue + } + } + } + + if (doClasses.length === 0) { + console.warn(`[devflare] No valid DO classes found in "${ref.name}"`) + return null + } + + // Bundle DO classes (native RPC, no wrappers needed) + const script = await bundleDOClasses(doClasses, ref.name) + if (!script) return null + + // Build DO bindings for Miniflare - use original class names (native RPC) + const durableObjects: Record = {} + for (const do_ of doClasses) { + durableObjects[do_.bindingName] = do_.className + } + + return { + name: ref.name, + script, + modules: true, + compatibilityDate: config.compatibilityDate ?? '2025-01-01', + durableObjects + } +} + +/** + * Bundle DO classes with RPC wrappers for Miniflare + */ +async function bundleDOClasses( + doClasses: Array<{ bindingName: string; className: string; scriptPath: string }>, + workerName: string +): Promise { + const cacheKey = `do:${doClasses.map((d) => `${d.scriptPath}::${d.className}`).join('|')}` + const cached = bundleCache.get(cacheKey) + if (cached) return cached + + const bun = getBunRuntime() + if (!bun) { + console.warn('[devflare] Bun runtime required for bundling DO classes') + return null + } + + try { + const { writeFileSync, mkdirSync, unlinkSync } = await import('fs') + + // Build imports and exports for DO classes + const imports = doClasses.map((d) => + `import { ${d.className} } from '${d.scriptPath.replace(/\\/g, '/')}'` + ).join('\n') + + const exports = doClasses.map((d) => d.className).join(', ') + + // Build the final script - no wrappers, native RPC via DurableObject base class + const entryCode = ` +${imports} + +// Re-export DO classes for Miniflare binding +export { ${exports} } + +// Default export with fetch handler +export default { + async fetch(request, env) { + return new Response('DO Worker: ${workerName}') + } +} +` + + // Write and bundle + const tempDir = join(dirname(doClasses[0].scriptPath), '.devflare') + mkdirSync(tempDir, { recursive: true }) + const entryPath = join(tempDir, `__do_entry_${workerName}.ts`) + writeFileSync(entryPath, entryCode) + + try { + const result = await bun.build({ + entrypoints: [entryPath], + target: 'browser', + format: 'esm', + minify: false, + external: ['cloudflare:workers', 'cloudflare:*'] + }) + + if (!result.success) { + console.warn(`[devflare] Failed to bundle DO worker "${workerName}": ${result.logs.join('\n')}`) + return null + } + + const bundledCode = await result.outputs[0].text() + bundleCache.set(cacheKey, bundledCode) + return bundledCode + } finally { + try { unlinkSync(entryPath) } catch { /* ignore */ } + } + } catch (error) { + console.warn(`[devflare] Error bundling DO worker "${workerName}":`, error) + return null + } +} diff --git a/packages/devflare/src/test/scheduled.ts b/packages/devflare/src/test/scheduled.ts new file mode 100644 index 0000000..d7ba605 --- /dev/null +++ b/packages/devflare/src/test/scheduled.ts @@ -0,0 +1,197 @@ +// ============================================================================= +// Scheduled Test Helper โ€” Trigger scheduled handlers in Bun tests +// ============================================================================= +// Usage: +// import { cf } from 'devflare/test' +// +// // Trigger the scheduled handler with a specific cron expression +// await cf.scheduled.trigger('0 */6 * * *') // Matches 6-hour cleanup +// +// // Trigger with current timestamp (useful for time-based logic) +// await cf.scheduled.trigger() +// ============================================================================= + +import type { ScheduledController } from '@cloudflare/workers-types' +import { join } from 'path' +import { createScheduledEvent, runWithEventContext } from '../runtime' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface ScheduledTriggerOptions { + /** Cron expression that triggered this event */ + cron?: string + /** Scheduled time (defaults to now) */ + scheduledTime?: number | Date +} + +export interface ScheduledTriggerResult { + /** Whether the handler completed successfully */ + success: boolean + /** Error message if handler threw */ + error?: string + /** Cron expression used */ + cron: string + /** Scheduled time */ + scheduledTime: number +} + +// ----------------------------------------------------------------------------- +// Global State (set by createTestContext) +// ----------------------------------------------------------------------------- + +let scheduledHandlerPath: string | null = null +let configDir: string | null = null +let testEnvGetter: (() => Record) | null = null + +// ----------------------------------------------------------------------------- +// Configuration (called by createTestContext) +// ----------------------------------------------------------------------------- + +/** + * Configure the scheduled test helper + * @internal Called by createTestContext to set up handler path and env + */ +export function configureScheduled(options: { + handlerPath: string | null + configDir: string + getEnv: () => Record +}): void { + scheduledHandlerPath = options.handlerPath + configDir = options.configDir + testEnvGetter = options.getEnv +} + +/** + * Reset scheduled helper state + * @internal Called when test context is disposed + */ +export function resetScheduledState(): void { + scheduledHandlerPath = null + configDir = null + testEnvGetter = null +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Trigger the scheduled handler. + * This directly invokes the scheduled handler function from your config. + * + * @param cronOrOptions - Cron expression string or options object + * @returns Result object with success status + * + * @example + * // Trigger with specific cron expression (every 6 hours) + * await cf.scheduled.trigger('0 0,6,12,18 * * *') + * + * @example + * // Trigger with options (Weekly Monday at midnight) + * await cf.scheduled.trigger({ + * cron: '0 0 * * 1', + * scheduledTime: new Date('2026-01-13T00:00:00Z') + * }) + * + * @example + * // Trigger without cron (just scheduled time) + * await cf.scheduled.trigger() + */ +async function trigger( + cronOrOptions?: string | ScheduledTriggerOptions +): Promise { + if (!scheduledHandlerPath) { + throw new Error( + 'Scheduled handler not configured. Make sure your devflare.config.ts has files.scheduled set, ' + + 'and the file exists at the specified path (default: src/scheduled.ts)' + ) + } + + if (!configDir || !testEnvGetter) { + throw new Error( + 'Scheduled helper not initialized. Call createTestContext() before using cf.scheduled.trigger()' + ) + } + + // Normalize options + const options: ScheduledTriggerOptions = + typeof cronOrOptions === 'string' + ? { cron: cronOrOptions } + : cronOrOptions ?? {} + + const cron = options.cron ?? '* * * * *' + const scheduledTime = + options.scheduledTime instanceof Date + ? options.scheduledTime.getTime() + : options.scheduledTime ?? Date.now() + + // Import the scheduled handler + const absolutePath = join(configDir, scheduledHandlerPath) + const handlerModule = await import(absolutePath) + + // Get the default export (the scheduled handler function) + const scheduledHandler = handlerModule.default ?? handlerModule.scheduled + if (typeof scheduledHandler !== 'function') { + throw new Error( + `Scheduled handler at "${scheduledHandlerPath}" must export a default function or named "scheduled" export.\n` + + + `Expected: export async function scheduled(event) { ... }` + ) + } + + // Create mock ScheduledController + const controller: ScheduledController = { + scheduledTime, + cron, + noRetry() { + // No-op in tests โ€” would prevent retries in production + } + } + + // Create execution context + const waitUntilPromises: Promise[] = [] + const ctx: ExecutionContext = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { }, + props: {} + } + + // Get the test env + const env = testEnvGetter() + const scheduledEvent = createScheduledEvent(controller, env, ctx) + + try { + // Call the handler + await runWithEventContext( + scheduledEvent, + () => scheduledHandler(scheduledEvent) + ) + + // Wait for all waitUntil promises + await Promise.all(waitUntilPromises) + + return { + success: true, + cron, + scheduledTime + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + cron, + scheduledTime + } + } +} + +// ----------------------------------------------------------------------------- +// Export +// ----------------------------------------------------------------------------- + +export const scheduled = { + trigger +} diff --git a/packages/devflare/src/test/should-skip.ts b/packages/devflare/src/test/should-skip.ts new file mode 100644 index 0000000..0b771c2 --- /dev/null +++ b/packages/devflare/src/test/should-skip.ts @@ -0,0 +1,268 @@ +// ============================================================================= +// Test Skip Helper โ€” Ergonomic API for skipping tests +// ============================================================================= +// Usage: +// import { shouldSkip } from 'devflare/test' +// const skipAI = await shouldSkip.ai +// describe.skipIf(skipAI)('AI tests', () => { ... }) +// ============================================================================= + +import { isAuthenticated } from '../cloudflare/auth' +import { getPrimaryAccount } from '../cloudflare/account' +import { getEffectiveAccountId } from '../cloudflare/preferences' +import { canProceedWithTest } from '../cloudflare/usage' +import { isRemoteModeActive, getRemoteModeStatus } from '../cloudflare/remote-config' +import type { CloudflareService } from '../cloudflare/types' +import { getContainerSkipReason } from './containers' + +// ----------------------------------------------------------------------------- +// Services That ALWAYS Require Remote Bindings +// ----------------------------------------------------------------------------- + +/** + * These services cannot be emulated locally โ€” they ALWAYS require + * a real connection to Cloudflare's infrastructure. + */ +const REMOTE_ONLY_SERVICES: Set = new Set([ + 'ai', + 'ai_search', + 'ai_gateway', + 'media', + 'mtls_certificates', + 'artifacts', + 'builds', + 'vectorize' +]) + +// ----------------------------------------------------------------------------- +// Skip Check Implementation +// ----------------------------------------------------------------------------- + +/** + * Cached skip results โ€” computed once at module load + * Each service gets a Promise that resolves to true if should SKIP + */ +const skipResults = new Map>() +let containerSkipResult: Promise | null = null + +/** + * Known operational error patterns that should cause skipping rather than failing. + * These are expected errors from network issues, API problems, auth failures, etc. + */ +const EXPECTED_ERROR_PATTERNS = [ + 'ECONNREFUSED', + 'ETIMEDOUT', + 'ENOTFOUND', + 'fetch failed', + 'network', + '401', + '403', + '429', + '500', + '502', + '503', + '504', + 'rate limit', + 'unauthorized', + 'forbidden', + 'timeout' +] + +/** + * Check if an error is an expected operational error that should cause skipping. + */ +function isExpectedError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const msg = error.message.toLowerCase() + return EXPECTED_ERROR_PATTERNS.some((pattern) => msg.includes(pattern.toLowerCase())) +} + +/** + * Compute whether to skip tests for a given service. + * Returns true if tests should be SKIPPED. + * Logs the reason to console. + * + * Rethrows unexpected errors (programming bugs) to fail tests loudly. + */ +async function computeSkip(service: CloudflareService): Promise { + try { + // 0. Remote-only services require explicit opt-in via DEVFLARE_REMOTE=1 or `devflare remote enable` + if (REMOTE_ONLY_SERVICES.has(service) && !isRemoteModeActive()) { + const status = getRemoteModeStatus() + console.log( + `โญ๏ธ ${service.toUpperCase()} tests skipped: Remote-only service.\n` + + ` Enable with: ${status.isEnabled ? '' : 'devflare remote enable'}\n` + + ` Or set: DEVFLARE_REMOTE=1\n` + + ` See: https://github.com/ArthurvdVenne/devflare#remote-testing` + ) + return true + } + + // 1. Check authentication + const isAuth = await isAuthenticated() + if (!isAuth) { + console.log( + `โญ๏ธ ${service.toUpperCase()} tests skipped: Not authenticated. Run: bunx wrangler login\n` + + ` See: https://github.com/ArthurvdVenne/devflare#authentication` + ) + return true + } + + // 2. Get effective account ID + const primary = await getPrimaryAccount() + if (!primary) { + console.log( + `โญ๏ธ ${service.toUpperCase()} tests skipped: No Cloudflare account found\n` + + ` See: https://github.com/ArthurvdVenne/devflare#authentication` + ) + return true + } + + const { accountId } = await getEffectiveAccountId(primary.id) + + // 3. Check usage limits (read-only: skip if namespace doesn't exist or check fails) + try { + const { allowed, reason } = await canProceedWithTest(accountId, service) + if (!allowed) { + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: ${reason}`) + return true + } + } catch { + // If limits can't be checked (e.g., KV not set up), allow the test to run + // The user hasn't configured limits, so we assume they want to run tests + } + + // All checks passed - don't skip + return false + } catch (error) { + // Only skip on expected operational errors (network, API, auth issues) + // Rethrow unexpected errors to fail tests loudly + if (isExpectedError(error)) { + const message = error instanceof Error ? error.message : 'Unknown error' + console.log(`โญ๏ธ ${service.toUpperCase()} tests skipped: ${message}`) + return true + } + + // Unexpected error โ€” rethrow to fail tests + throw error + } +} + +/** + * Get or compute the skip result for a service + */ +function getSkipResult(service: CloudflareService): Promise { + let result = skipResults.get(service) + if (!result) { + result = computeSkip(service) + skipResults.set(service, result) + } + return result +} + +function getContainerSkipResult(): Promise { + if (!containerSkipResult) { + containerSkipResult = getContainerSkipReason().then((reason) => { + if (reason) { + console.log(`CONTAINERS tests skipped: ${reason}`) + return true + } + return false + }) + } + return containerSkipResult +} + +// ----------------------------------------------------------------------------- +// Public API โ€” Property-based access +// ----------------------------------------------------------------------------- + +/** + * Skip helper with property-based access for each service. + * Each property returns a Promise where true = SKIP the tests. + * + * Usage: + * ```ts + * import { shouldSkip } from 'devflare/test' + * + * describe.skipIf(shouldSkip.ai)('AI tests', () => { + * // These tests only run when authenticated and within limits + * }) + * ``` + */ +export const shouldSkip = { + /** Skip AI tests if not authenticated or over limits */ + get ai(): Promise { + return getSkipResult('ai') + }, + + /** Skip AI Search remote integration tests unless remote mode and Cloudflare auth are available */ + get aiSearch(): Promise { + return getSkipResult('ai_search') + }, + + /** Skip AI Gateway remote integration tests unless remote mode and Cloudflare auth are available */ + get aiGateway(): Promise { + return getSkipResult('ai_gateway') + }, + + /** Skip Vectorize tests if not authenticated or over limits */ + get vectorize(): Promise { + return getSkipResult('vectorize') + }, + + /** Skip Workers tests if not authenticated or over limits */ + get workers(): Promise { + return getSkipResult('workers') + }, + + /** Skip KV tests if not authenticated or over limits */ + get kv(): Promise { + return getSkipResult('kv') + }, + + /** Skip D1 tests if not authenticated or over limits */ + get d1(): Promise { + return getSkipResult('d1') + }, + + /** Skip R2 tests if not authenticated or over limits */ + get r2(): Promise { + return getSkipResult('r2') + }, + + /** Skip Queues tests if not authenticated or over limits */ + get queues(): Promise { + return getSkipResult('queues') + }, + + /** Skip Durable Objects tests if not authenticated or over limits */ + get durableObjects(): Promise { + return getSkipResult('durable_objects') + }, + + /** Skip Media Transformations remote integration tests unless remote mode and Cloudflare auth are available */ + get media(): Promise { + return getSkipResult('media') + }, + + /** Skip mTLS Certificate remote integration tests unless remote mode and Cloudflare auth are available */ + get mtlsCertificates(): Promise { + return getSkipResult('mtls_certificates') + }, + + /** Skip Artifacts remote integration tests unless remote mode and Cloudflare auth are available */ + get artifacts(): Promise { + return getSkipResult('artifacts') + }, + + /** Skip Cloudflare Builds integration tests unless remote mode and Cloudflare auth are available */ + get builds(): Promise { + return getSkipResult('builds') + }, + + /** Skip local Container tests unless explicitly enabled and Docker/Podman is reachable */ + get containers(): Promise { + return getContainerSkipResult() + } +} as const diff --git a/packages/devflare/src/test/simple-context-bindings.ts b/packages/devflare/src/test/simple-context-bindings.ts new file mode 100644 index 0000000..7bfacef --- /dev/null +++ b/packages/devflare/src/test/simple-context-bindings.ts @@ -0,0 +1,70 @@ +// ============================================================================= +// Test Context โ€” Remote/static binding initialization +// ============================================================================= +// Pure helper extracted from createTestContext(). Builds the initial +// `remoteBindings` map from the resolved devflare config: remote AI/Vectorize +// proxies (only when remote mode is active), config `vars`, and local +// sendEmail bindings. +// ============================================================================= + +import type { DevflareConfig } from '../config' +import { isRemoteModeActive } from '../cloudflare/remote-config' +import { createRemoteAI } from './remote-ai' +import { createRemoteVectorize } from './remote-vectorize' +import { createLocalSendEmailBinding } from '../utils/send-email' +import { createMockVersionMetadata } from './utilities' +import { createLocalWorkerLoaderBinding } from '../shims/local-worker-loader' + +/** + * Build the initial remote/static binding map for a test context. + * + * - When `isRemoteModeActive()`, registers `RemoteAI` / `RemoteVectorize` + * proxies for every `bindings.ai` / `bindings.vectorize` entry. Otherwise + * those bindings are left to come from Miniflare (or remain absent). + * - `config.vars` are always copied in as-is. + * - `config.bindings.sendEmail` is always wired to the local SendEmail + * binding (so tests can intercept outgoing mail without remote setup). + */ +export function buildRemoteAndStaticBindings(config: DevflareConfig): Record { + const remoteBindings: Record = {} + + if (isRemoteModeActive()) { + if (config.bindings?.ai) { + const aiBindingName = config.bindings.ai.binding || 'AI' + remoteBindings[aiBindingName] = createRemoteAI(config.accountId) + } + + if (config.bindings?.vectorize) { + for (const [name, vectorConfig] of Object.entries(config.bindings.vectorize)) { + remoteBindings[name] = createRemoteVectorize( + vectorConfig.indexName, + config.accountId + ) + } + } + } + + if (config.vars) { + for (const [key, value] of Object.entries(config.vars)) { + remoteBindings[key] = value + } + } + + if (config.bindings?.sendEmail) { + for (const [name, binding] of Object.entries(config.bindings.sendEmail)) { + remoteBindings[name] = createLocalSendEmailBinding(binding) + } + } + + if (config.bindings?.workerLoaders) { + for (const name of Object.keys(config.bindings.workerLoaders)) { + remoteBindings[name] = createLocalWorkerLoaderBinding() + } + } + + if (config.bindings?.versionMetadata) { + remoteBindings[config.bindings.versionMetadata.binding] = createMockVersionMetadata() + } + + return remoteBindings +} diff --git a/packages/devflare/src/test/simple-context-durable-objects.ts b/packages/devflare/src/test/simple-context-durable-objects.ts new file mode 100644 index 0000000..2a3f1fc --- /dev/null +++ b/packages/devflare/src/test/simple-context-durable-objects.ts @@ -0,0 +1,262 @@ +import { mkdirSync, writeFileSync } from 'fs' +import { readFile } from 'fs/promises' +import { dirname, join } from 'path' +import { loadConfig, normalizeDOBinding, type DevflareConfig } from '../config' +import { DEFAULT_DO_PATTERN, findFiles } from '../utils/glob' +import { buildGatewayScript } from './simple-context-gateway-script' +import { getBunRuntime } from './simple-context-paths' +import { bundleWorkflowEntrypointScript } from '../workflows/local-workflow-entrypoints' + +/** + * Find all exported class names in a TypeScript/JavaScript file. + */ +function findExportedClasses(code: string): string[] { + const classes: string[] = [] + const classPattern = /export\s+class\s+(\w+)/g + + let match: RegExpExecArray | null + while ((match = classPattern.exec(code)) !== null) { + classes.push(match[1]) + } + + return classes +} + +function classSupportsNativeDurableObjectRpc(code: string, className: string): boolean { + const nativeRpcPattern = new RegExp(`export\\s+class\\s+${className}\\s+extends\\s+DurableObject\\b`) + return nativeRpcPattern.test(code) +} + +function toGeneratedIdentifier(value: string): string { + const normalized = value.replace(/[^A-Za-z0-9_$]/g, '_') + return /^[A-Za-z_$]/.test(normalized) ? normalized : `_${normalized}` +} + +interface LocalDurableObjectInfo { + name: string + className: string + scriptPath: string + nativeRpc: boolean + runtimeClassName: string +} + +async function discoverLocalDurableObjectClasses( + config: DevflareConfig, + configDir: string +): Promise> { + const classToFilePath = new Map() + const doPatternConfig = config.files?.durableObjects + const doPattern = typeof doPatternConfig === 'string' ? doPatternConfig : DEFAULT_DO_PATTERN + + if (doPatternConfig === false) { + return classToFilePath + } + + const doFiles = await findFiles(doPattern, { cwd: configDir }) + for (const filePath of doFiles) { + try { + const code = await readFile(filePath, 'utf-8') + const classNames = findExportedClasses(code) + + for (const className of classNames) { + classToFilePath.set(className, { + filePath, + nativeRpc: classSupportsNativeDurableObjectRpc(code, className) + }) + } + } catch { + // Skip files that can't be read. + } + } + + return classToFilePath +} + +async function resolveLocalDurableObjects( + config: DevflareConfig, + configDir: string +): Promise<{ + doConfig: Record + doInfos: LocalDurableObjectInfo[] +}> { + const doConfig: Record = {} + const doInfos: LocalDurableObjectInfo[] = [] + const classToFilePath = await discoverLocalDurableObjectClasses(config, configDir) + + for (const [name, rawDoInfo] of Object.entries(config.bindings?.durableObjects ?? {})) { + const doInfo = normalizeDOBinding(rawDoInfo) + + if (doInfo.__ref) { + continue + } + + let scriptPath: string + let nativeRpc = false + + if (doInfo.kind === 'cross-worker' && doInfo.scriptName) { + scriptPath = join(configDir, 'src', doInfo.scriptName) + try { + const code = await readFile(scriptPath, 'utf-8') + nativeRpc = classSupportsNativeDurableObjectRpc(code, doInfo.className) + } catch { + nativeRpc = false + } + } else { + const discoveredClass = classToFilePath.get(doInfo.className) + if (!discoveredClass) { + throw new Error( + `Durable object ${name} (className: '${doInfo.className}') not found.\n` + + `Either:\n` + + ` 1. Set files.durableObjects pattern in config (e.g., 'src/do.*.ts')\n` + + ` 2. Use explicit scriptName: { className: '${doInfo.className}', scriptName: 'do.file.ts' }` + ) + } + + scriptPath = discoveredClass.filePath + nativeRpc = discoveredClass.nativeRpc + } + + const runtimeClassName = nativeRpc + ? doInfo.className + : `__Devflare${toGeneratedIdentifier(name)}RpcWrapper` + + doConfig[name] = runtimeClassName + doInfos.push({ + name, + className: doInfo.className, + scriptPath, + nativeRpc, + runtimeClassName + }) + } + + return { + doConfig, + doInfos + } +} + +function buildWrapperCode(doInfos: LocalDurableObjectInfo[]): string { + return doInfos + .filter((info) => !info.nativeRpc) + .map((info) => ` +export class ${info.runtimeClassName} { + constructor(state, env) { + this.__instance = new ${info.className}(state, env) + } + + async fetch(request) { + const url = new URL(request.url) + if (request.method !== 'POST' || url.pathname !== '/_rpc') { + return new Response('Not found', { status: 404 }) + } + + try { + const payload = await request.json() + const method = payload?.method + const params = Array.isArray(payload?.params) ? payload.params : [] + const target = this.__instance?.[method] + + if (typeof target !== 'function') { + return new Response(JSON.stringify({ + ok: false, + error: { message: 'Method not found: ' + String(method) } + }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }) + } + + let result = await target.apply(this.__instance, params) + result = __encodeTransport(result) + + return new Response(JSON.stringify({ ok: true, result }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + error: { message: error instanceof Error ? error.message : String(error) } + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + } +}`.trim()) + .join('\n\n') +} + +async function bundleDurableObjectModules( + configDir: string, + doInfos: LocalDurableObjectInfo[], + transportFile: string | null +): Promise { + const virtualImports: string[] = [] + const virtualExports: string[] = [] + + if (transportFile) { + const transportPath = join(configDir, transportFile) + virtualImports.push(`import { transport } from '${transportPath.replace(/\\/g, '/')}'`) + virtualExports.push('export { transport }') + } + + for (const info of doInfos) { + virtualImports.push(`import { ${info.className} } from '${info.scriptPath.replace(/\\/g, '/')}'`) + virtualExports.push(`export { ${info.className} }`) + } + + if (virtualImports.length === 0) { + return '' + } + + const virtualEntry = [...virtualImports, '', ...virtualExports].join('\n') + const virtualPath = join(configDir, '.devflare', '__test_entry.ts') + mkdirSync(dirname(virtualPath), { recursive: true }) + writeFileSync(virtualPath, virtualEntry) + + const bun = getBunRuntime() + if (!bun) { + throw new Error('Bun runtime is required for createTestContext with Durable Objects') + } + + const result = await bun.build({ + entrypoints: [virtualPath], + target: 'browser', + format: 'esm', + minify: false, + external: ['cloudflare:workers', 'cloudflare:*'] + }) + + if (!result.success) { + throw new Error(`Failed to bundle test entry: ${result.logs.join('\n')}`) + } + + return await result.outputs[0].text() +} + +export async function buildDurableObjectGateway(config: DevflareConfig, configDir: string, transportFile: string | null): Promise<{ + durableObjects?: Record + script: string +}> { + const workflowEntrypointScript = await bundleWorkflowEntrypointScript(config, configDir) + + if (!config.bindings?.durableObjects) { + return { + script: buildGatewayScript(workflowEntrypointScript, '') + } + } + + const { doConfig, doInfos } = await resolveLocalDurableObjects(config, configDir) + const bundledCode = await bundleDurableObjectModules(configDir, doInfos, transportFile) + const wrapperCode = buildWrapperCode(doInfos) + const entrypointCode = [workflowEntrypointScript, bundledCode].filter(Boolean).join('\n\n') + const nativeRpcBindingNames = doInfos + .filter((info) => info.nativeRpc) + .map((info) => info.name) + + return { + durableObjects: doConfig, + script: buildGatewayScript(entrypointCode, wrapperCode, nativeRpcBindingNames) + } +} diff --git a/packages/devflare/src/test/simple-context-env.ts b/packages/devflare/src/test/simple-context-env.ts new file mode 100644 index 0000000..9d7b5a4 --- /dev/null +++ b/packages/devflare/src/test/simple-context-env.ts @@ -0,0 +1,134 @@ +// ============================================================================= +// Test Context โ€” Handler wiring + env-accessor proxies +// ============================================================================= +// Pure helpers extracted from createTestContext(). These wire up the per- +// surface handler helpers (queue / scheduled / worker fetch / tail / email) +// and build the `env` proxies returned to user code. +// ============================================================================= + +import type { BindingHints } from '../bridge/proxy' +import type { ResolvedHandlerPaths } from './simple-context-handlers' +import { configureEmail } from './email' +import { configureQueue } from './queue' +import { configureScheduled } from './scheduled' +import { configureTail } from './tail' +import { configureWorker } from './worker' + +interface TestStateView { + envProxy: Record | null + remoteBindings: Record | null + miniflareBindings: Record | null +} + +/** + * Wire up every per-surface handler helper (queue / scheduled / worker / + * tail / email) with the same `configDir` + `getEnv` accessor. + */ +export function configureSurfaceHandlers(input: { + handlerPaths: ResolvedHandlerPaths + configDir: string + activePort: number + getEnv: () => Record +}): void { + const { handlerPaths, configDir, activePort, getEnv } = input + + configureQueue({ + handlerPath: handlerPaths.queue, + configDir, + getEnv + }) + configureScheduled({ + handlerPath: handlerPaths.scheduled, + configDir, + getEnv + }) + configureWorker({ + handlerPath: handlerPaths.fetch, + routes: handlerPaths.routes?.routes.map((route) => ({ + filePath: route.filePath, + routePath: route.routePath, + segments: route.segments + })) ?? [], + configDir, + getEnv + }) + configureTail({ + handlerPath: handlerPaths.tail, + configDir, + getEnv + }) + configureEmail({ + port: activePort, + handlerPath: handlerPaths.email, + configDir, + getEnv + }) +} + +/** + * Build the bridge-backed env accessor used by single-worker test contexts. + * + * Resolution order, given a property access: + * 1. Remote bindings (AI/Vectorize/vars/sendEmail) registered up-front. + * 2. For non-DO/non-service hints: Miniflare binding (raw KV/D1/R2/etc). + * 3. Bridge env proxy (for everything else, including DOs and services). + * 4. Final fallback: Miniflare binding (when the hint preferred bridge but + * the proxy did not surface it). + */ +export function createBridgeEnvAccessor( + state: TestStateView, + hints: BindingHints, + shouldPreferBridgeBinding: (hint: BindingHints[string] | undefined) => boolean +): Record { + return new Proxy({}, { + get(_, prop: string) { + const hint = hints[prop] + const prefersBridgeBinding = shouldPreferBridgeBinding(hint) + + if (state.remoteBindings && prop in state.remoteBindings) { + return state.remoteBindings[prop] + } + if (!prefersBridgeBinding && state.miniflareBindings && prop in state.miniflareBindings) { + return state.miniflareBindings[prop] + } + if (state.envProxy) { + return state.envProxy[prop] + } + if (prefersBridgeBinding && state.miniflareBindings && prop in state.miniflareBindings) { + return state.miniflareBindings[prop] + } + return undefined + }, + has(_, prop: string) { + return Boolean( + (state.remoteBindings && prop in state.remoteBindings) + || (state.miniflareBindings && prop in state.miniflareBindings) + || (state.envProxy !== null) + ) + } + }) as Record +} + +/** + * Build the simpler env accessor used by multi-worker test contexts (no + * bridge-backed proxy: services + DOs go through Miniflare's own bindings). + */ +export function createMultiWorkerEnvAccessor(state: TestStateView): Record { + return new Proxy({}, { + get(_, prop: string) { + if (state.remoteBindings && prop in state.remoteBindings) { + return state.remoteBindings[prop] + } + if (state.miniflareBindings && prop in state.miniflareBindings) { + return state.miniflareBindings[prop] + } + return undefined + }, + has(_, prop: string) { + return Boolean( + (state.remoteBindings && prop in state.remoteBindings) + || (state.miniflareBindings && prop in state.miniflareBindings) + ) + } + }) as Record +} diff --git a/packages/devflare/src/test/simple-context-gateway-script.ts b/packages/devflare/src/test/simple-context-gateway-script.ts new file mode 100644 index 0000000..3c04af4 --- /dev/null +++ b/packages/devflare/src/test/simple-context-gateway-script.ts @@ -0,0 +1,217 @@ +export function buildGatewayScript( + bundledCode: string, + wrappers: string, + nativeRpcBindingNames: string[] = [] +): string { + const nativeRpcBindingsLiteral = JSON.stringify(nativeRpcBindingNames) + + return ` +// Bundled transport + DO classes +${bundledCode} + +// DO Wrappers with RPC +${wrappers} + +const __nativeRpcBindings = new Set(${nativeRpcBindingsLiteral}) + +// Transport encoding helper +const __transportEncoders = typeof transport !== 'undefined' ? transport : {} + +function __encodeTransport(value) { + if (value === null || value === undefined) return value + + // Try each encoder + for (const [typeName, transporter] of Object.entries(__transportEncoders)) { + const encoded = transporter.encode(value) + if (encoded !== false && encoded !== undefined) { + return { __transport: typeName, value: encoded } + } + } + + // Recursively encode arrays and objects + if (Array.isArray(value)) { + return value.map(__encodeTransport) + } + if (typeof value === 'object') { + const result = {} + for (const [k, v] of Object.entries(value)) { + result[k] = __encodeTransport(v) + } + return result + } + + return value +} + +// Gateway with WebSocket RPC +export default { + async fetch(request, env) { + if (request.headers.get('Upgrade') === 'websocket') { + const { 0: client, 1: server } = new WebSocketPair() + server.accept() + server.addEventListener('message', async (e) => { + try { + const m = JSON.parse(e.data) + if (m.t === 'rpc.call') { + const result = await executeRpc(env, m.method, m.params) + server.send(JSON.stringify({ t: 'rpc.ok', id: m.id, result })) + } + } catch (error) { + server.send(JSON.stringify({ t: 'rpc.err', id: 'unknown', error: { code: 'RPC_ERROR', message: error.message } })) + } + }) + return new Response(null, { status: 101, webSocket: client }) + } + return new Response('Gateway') + } +} + +async function executeRpc(env, method, params) { + const [bindingName, ...rest] = method.split('.') + let op = rest.join('.') + const binding = env[bindingName] + const RAW_EMAIL = 'EmailMessage::raw' + if (!binding) throw new Error('Binding not found: ' + bindingName) + + // Normalize namespaced op names (kv.*, r2.*, d1.*, do.*, queue.*, ai.*, var.*) + // down to the legacy verbs this dispatcher historically used. The bridge + // proxy always emits namespaced forms (see src/bridge/proxy.ts and B3 in + // REMAINING.md); this keeps the dispatcher backwards-compatible while the + // rest of the codebase converges on the namespaced convention. + if (op.indexOf('kv.') === 0) op = op.slice(3) + else if (op.indexOf('do.') === 0) { + const tail = op.slice(3) + if (tail === 'fetch') op = 'stub.fetch' + else if (tail === 'rpc') op = 'stub.rpc' + else op = tail + } + else if (op.indexOf('queue.') === 0) op = op.slice(6) + else if (op.indexOf('ai.') === 0) op = op.slice(3) + else if (op.indexOf('var.') === 0) op = op.slice(4) + else if (op.indexOf('d1.stmt.') === 0) op = 'prepare.' + op.slice('d1.stmt.'.length) + else if (op.indexOf('d1.') === 0) op = op.slice(3) + // r2.* and email.* keep their existing prefixes + + // KV operations + if (op === 'get') return binding.get(params[0], params[1]) + if (op === 'put') return binding.put(params[0], params[1], params[2]) + if (op === 'delete') return binding.delete(params[0]) + if (op === 'list') return binding.list(params[0]) + if (op === 'getWithMetadata') return binding.getWithMetadata(params[0], params[1]) + + // R2 operations + if (op === 'r2.get') return binding.get(params[0], params[1]) + if (op === 'r2.put') return binding.put(params[0], params[1], params[2]) + if (op === 'r2.delete') return binding.delete(params[0]) + if (op === 'r2.list') return binding.list(params[0]) + if (op === 'r2.head' || op === 'head') return binding.head(params[0]) + + // D1 operations + if (op === 'exec') return binding.exec(params[0]) + if (op === 'dump') return binding.dump() + if (op === 'batch') { + const stmts = params[0].map(s => { + const stmt = binding.prepare(s.sql) + return s.bindings?.length ? stmt.bind(...s.bindings) : stmt + }) + return binding.batch(stmts) + } + if (op === 'prepare.run') return binding.prepare(params[0]).bind(...(params[1] || [])).run() + if (op === 'prepare.all') return binding.prepare(params[0]).bind(...(params[1] || [])).all() + if (op === 'prepare.first') return binding.prepare(params[0]).bind(...(params[1] || [])).first(params[2]) + if (op === 'prepare.raw') return binding.prepare(params[0]).bind(...(params[1] || [])).raw({ columnNames: params[2] }) + + // Send email operations + if (op === 'email.send') { + return binding.send(__normalizeEmailMessage(params[0])) + } + + // DO operations + if (op === 'idFromName') { + return { __type: 'DOId', hex: binding.idFromName(params[0]).toString() } + } + if (op === 'stub.rpc') { + const [, idSerialized, rpcMethod, rpcParams] = params + const stub = binding.get(binding.idFromString(idSerialized.hex)) + + if (__nativeRpcBindings.has(bindingName) && typeof stub[rpcMethod] === 'function') { + let result = await stub[rpcMethod](...(rpcParams || [])) + result = __encodeTransport(result) + return result + } + + const response = await stub.fetch(new Request('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: rpcMethod, params: rpcParams || [] }) + })) + + const payload = await response.json() + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error?.message || ('DO RPC failed with status ' + response.status)) + } + + return payload.result + } + + throw new Error('Unknown operation: ' + method) +} + +function __createEmailMessageRaw(raw) { + if (typeof raw === 'string' || raw instanceof ReadableStream) { + return raw + } + if (raw instanceof Uint8Array || raw instanceof ArrayBuffer) { + return new Response(raw).body + } + throw new Error('Unsupported EmailMessage raw payload') +} + +function __buildRawEmail(message) { + const lines = [] + const messageId = '<' + Date.now() + '-' + Math.random().toString(36).slice(2) + '@devflare.dev>' + + lines.push('From: ' + message.from) + lines.push('To: ' + (Array.isArray(message.to) ? message.to.join(', ') : message.to)) + lines.push('Date: ' + new Date().toUTCString()) + lines.push('Message-ID: ' + messageId) + + if (message.subject) lines.push('Subject: ' + message.subject) + if (message.replyTo) lines.push('Reply-To: ' + String(message.replyTo)) + if (message.cc) lines.push('Cc: ' + (Array.isArray(message.cc) ? message.cc.join(', ') : message.cc)) + if (message.bcc) lines.push('Bcc: ' + (Array.isArray(message.bcc) ? message.bcc.join(', ') : message.bcc)) + + for (const [key, value] of Object.entries(message.headers || {})) { + lines.push(key + ': ' + value) + } + + lines.push('MIME-Version: 1.0') + lines.push('Content-Type: ' + (message.html ? 'text/html' : 'text/plain') + '; charset=UTF-8') + lines.push('') + lines.push(String(message.html ?? message.text ?? '').replace(/\\r?\\n/g, '\\r\\n')) + + return lines.join('\\r\\n') +} + +function __normalizeEmailMessage(message) { + if (!message || typeof message !== 'object' || !('from' in message) || !('to' in message)) { + return message + } + if ('EmailMessage::raw' in message) { + return message + } + if ('raw' in message && message.raw !== undefined) { + return { + from: message.from, + to: message.to, + [RAW_EMAIL]: __createEmailMessageRaw(message.raw) + } + } + return { + from: message.from, + to: message.to, + [RAW_EMAIL]: __createEmailMessageRaw(__buildRawEmail(message)) + } +} +` +} diff --git a/packages/devflare/src/test/simple-context-handlers.ts b/packages/devflare/src/test/simple-context-handlers.ts new file mode 100644 index 0000000..ea872ad --- /dev/null +++ b/packages/devflare/src/test/simple-context-handlers.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// Handler-path resolution for createTestContext +// ============================================================================= +// Resolves the per-handler file paths (`fetch`, `queue`, `scheduled`, `email`, +// `tail`) that createTestContext wires into the test bridge. Honours +// user-supplied `config.files.*` overrides, falls back to convention-based +// defaults under `src/`, and treats `false` as an explicit opt-out. +// ============================================================================= + +import { join } from 'path' +import type { DevflareConfig } from '../config' +import { discoverRoutes, type RouteDiscoveryResult } from '../worker-entry/routes' + +const DEFAULT_FETCH_PATH = 'src/fetch.ts' +const DEFAULT_QUEUE_PATH = 'src/queue.ts' +const DEFAULT_SCHEDULED_PATH = 'src/scheduled.ts' +const DEFAULT_EMAIL_PATH = 'src/email.ts' +const DEFAULT_TAIL_PATH = 'src/tail.ts' + +export interface ResolvedHandlerPaths { + fetch: string | null + queue: string | null + scheduled: string | null + email: string | null + tail: string | null + routes: RouteDiscoveryResult | null +} + +async function resolveHandlerPath( + configDir: string, + configValue: string | false | undefined, + defaultPath: string +): Promise { + if (typeof configValue === 'string') { + return configValue + } + if (configValue === false) { + return null + } + + const defaultAbsolute = join(configDir, defaultPath) + try { + const fs = await import('fs/promises') + await fs.access(defaultAbsolute) + return defaultPath + } catch { + return null + } +} + +export async function resolveHandlerPaths( + configDir: string, + config: DevflareConfig +): Promise { + const [fetch, queue, scheduled, email, tail, routes] = await Promise.all([ + resolveHandlerPath(configDir, config.files?.fetch, DEFAULT_FETCH_PATH), + resolveHandlerPath(configDir, config.files?.queue, DEFAULT_QUEUE_PATH), + resolveHandlerPath(configDir, config.files?.scheduled, DEFAULT_SCHEDULED_PATH), + resolveHandlerPath(configDir, config.files?.email, DEFAULT_EMAIL_PATH), + resolveHandlerPath(configDir, config.files?.tail, DEFAULT_TAIL_PATH), + discoverRoutes(configDir, config) + ]) + + return { fetch, queue, scheduled, email, tail, routes } +} diff --git a/packages/devflare/src/test/simple-context-lifecycle.ts b/packages/devflare/src/test/simple-context-lifecycle.ts new file mode 100644 index 0000000..26cb309 --- /dev/null +++ b/packages/devflare/src/test/simple-context-lifecycle.ts @@ -0,0 +1,116 @@ +// ============================================================================= +// Test Context โ€” Config resolution + dispose helpers +// ============================================================================= +// Pure helpers extracted from createTestContext(): +// - resolveTestContextConfig: locate and load the devflare.config.* file +// - createDisposeContext: build the dispose() function that tears down +// bridge client + miniflare + per-handler state +// ============================================================================= + +import { dirname, resolve } from 'path' +import { loadConfig, resolveConfigEnvVars } from '../config' +import { applyLocalDevVarsToConfig } from '../config/local-dev-vars' +import type { DevflareConfig } from '../config' +import type { BridgeClient } from '../bridge/client' +import { __clearTestContext } from '../env' +import { findNearestConfig, getCallerDirectory } from './simple-context-paths' +import { resetEmailState } from './email' +import { resetQueueState } from './queue' +import { resetScheduledState } from './scheduled' +import { resetTailState } from './tail' +import { resetWorkerState } from './worker' +import { stopActiveContainers } from './containers' +import { disposeLocalWorkerLoaderBindings } from '../shims/local-worker-loader' + +interface DisposeStateView { + client: BridgeClient | null + miniflare: any + envProxy: Record | null + transportDecode: unknown + remoteBindings: Record | null + miniflareBindings: Record | null +} + +export interface ResolvedTestContextConfig { + absolutePath: string + configDir: string + config: DevflareConfig +} + +/** + * Resolve and load the devflare config for the test context. + * + * If `configPath` is given, it is interpreted relative to the caller's + * directory (the file that invoked `createTestContext()`). Otherwise the + * resolver walks upward from the caller's directory looking for a supported + * `devflare.config.*` file. + */ +export async function resolveTestContextConfig( + configPath: string | undefined, + callerDir: string = getCallerDirectory() +): Promise { + let absolutePath: string + + if (configPath) { + absolutePath = resolve(callerDir, configPath) + } else { + const found = await findNearestConfig(callerDir) + if (!found) { + throw new Error( + `Could not find a devflare config file. Searched upward from: ${callerDir}\n` + + `Expected one of: devflare.config.ts, devflare.config.mts, devflare.config.js, devflare.config.mjs\n` + + `Either create a config file or provide an explicit path: createTestContext('./path/to/config.ts')` + ) + } + absolutePath = found + } + + const configDir = dirname(absolutePath) + const loadedConfig = await loadConfig({ + cwd: configDir, + configFile: absolutePath.split(/[/\\]/).pop() + }) + const envResolvedConfig = await resolveConfigEnvVars(loadedConfig, { + cwd: configDir, + configPath: absolutePath, + mode: 'dev' + }) + const config = await applyLocalDevVarsToConfig(envResolvedConfig, { + cwd: configDir, + configPath: absolutePath + }) + + return { absolutePath, configDir, config } +} + +/** + * Build the dispose() function that tears down a test context. Disconnects + * the bridge client, disposes Miniflare, clears per-handler global state, + * and clears the registered test-context env accessor. + */ +export function createDisposeContext(state: DisposeStateView): () => Promise { + return async () => { + if (state.client) { + await state.client.disconnect() + state.client = null + } + if (state.miniflare) { + await state.miniflare.dispose() + state.miniflare = null + } + await disposeLocalWorkerLoaderBindings() + await stopActiveContainers() + state.envProxy = null + state.transportDecode = null + state.remoteBindings = null + state.miniflareBindings = null + + resetQueueState() + resetScheduledState() + resetWorkerState() + resetTailState() + resetEmailState() + + __clearTestContext() + } +} diff --git a/packages/devflare/src/test/simple-context-mfconfig.ts b/packages/devflare/src/test/simple-context-mfconfig.ts new file mode 100644 index 0000000..eddd203 --- /dev/null +++ b/packages/devflare/src/test/simple-context-mfconfig.ts @@ -0,0 +1,288 @@ +// ============================================================================= +// Test-context inline-bridge Miniflare config builder +// ============================================================================= +// Translates the user's `config.bindings` (KV / R2 / D1 / queues / send_email) +// and `config.vars` into the seed `mfConfig` object that the bridge gateway +// script will run as a single worker. This is the "single-worker" baseline; +// `applyMultiWorkerConfig` rewrites it in place when cross-worker bindings +// are detected. +// ============================================================================= + +import { + getLocalD1DatabaseIdentifier, + normalizeArtifactsBinding, + normalizeDispatchNamespaceBinding, + normalizeImagesBinding, + normalizeMediaBinding, + normalizeMtlsCertificateBinding, + normalizePipelineBinding, + normalizeSecretsStoreBinding, + normalizeWorkflowBinding +} from '../config' +import type { DevflareConfig } from '../config' +import { buildHyperdrivesConfig } from '../dev-server/miniflare-bindings' +import { buildLocalSecretWrappedBindingConfig } from '../secrets/local-secrets' +import { buildLocalBindingShimServiceConfig } from '../shims/local-media-bindings' + +export interface BuildInlineBridgeMfConfigOptions { + cwd?: string +} + +/** + * Build the seed Miniflare config for an inline (single-worker) bridge. + * Pure: no I/O, no closures. + */ +export function buildInlineBridgeMfConfig( + config: DevflareConfig, + options: BuildInlineBridgeMfConfigOptions = {} +): any { + const localWorkerBindings: Record = config.vars ?? {} + const localSecretWrappedBindingConfig = options.cwd + ? buildLocalSecretWrappedBindingConfig(config, options.cwd) + : undefined + const localBindingShimServiceConfig = buildLocalBindingShimServiceConfig(config) + const localSecretBindingNames = new Set( + localSecretWrappedBindingConfig?.localBindingNames ?? [] + ) + const mfConfig: any = { + modules: true, + compatibilityDate: config.compatibilityDate ?? '2025-01-01', + ...(config.compatibilityFlags && { compatibilityFlags: config.compatibilityFlags }) + } + + if (config.bindings?.kv) { + mfConfig.kvNamespaces = Object.keys(config.bindings.kv) + } + if (config.bindings?.r2) { + mfConfig.r2Buckets = Object.keys(config.bindings.r2) + } + if (config.bindings?.d1) { + mfConfig.d1Databases = Object.fromEntries( + Object.entries(config.bindings.d1).map(([bindingName, bindingConfig]) => { + return [bindingName, getLocalD1DatabaseIdentifier(bindingConfig)] + }) + ) + } + const hyperdrivesConfig = buildHyperdrivesConfig(config.bindings ?? {}) + if (hyperdrivesConfig) { + mfConfig.hyperdrives = hyperdrivesConfig + } + + if (config.bindings?.queues?.producers) { + const queueProducers: Record = {} + for (const [bindingName, queueName] of Object.entries(config.bindings.queues.producers)) { + queueProducers[bindingName] = { queueName } + } + mfConfig.queueProducers = queueProducers + } + + if (config.bindings?.rateLimits) { + mfConfig.ratelimits = Object.fromEntries( + Object.entries(config.bindings.rateLimits).map(([bindingName, binding]) => [ + bindingName, + { + simple: { + limit: binding.simple.limit, + period: binding.simple.period + } + } + ]) + ) + } + + if (config.bindings?.versionMetadata) { + mfConfig.versionMetadata = config.bindings.versionMetadata.binding + } + + if (config.bindings?.workerLoaders) { + mfConfig.workerLoaders = Object.fromEntries( + Object.keys(config.bindings.workerLoaders).map((bindingName) => [bindingName, {}]) + ) + } + + if (config.bindings?.mtlsCertificates) { + mfConfig.mtlsCertificates = Object.fromEntries( + Object.entries(config.bindings.mtlsCertificates).map(([bindingName, binding]) => { + const normalized = normalizeMtlsCertificateBinding(binding) + return [ + bindingName, + { + certificate_id: normalized.certificateId + } + ] + }) + ) + } + + if (config.bindings?.dispatchNamespaces) { + mfConfig.dispatchNamespaces = Object.fromEntries( + Object.entries(config.bindings.dispatchNamespaces).map(([bindingName, binding]) => { + const normalized = normalizeDispatchNamespaceBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) + } + + if (config.bindings?.workflows) { + mfConfig.workflows = Object.fromEntries( + Object.entries(config.bindings.workflows).map(([bindingName, binding]) => { + const normalized = normalizeWorkflowBinding(binding) + return [ + bindingName, + { + name: normalized.name, + className: normalized.className, + ...(normalized.scriptName && { scriptName: normalized.scriptName }), + ...(normalized.limits && { stepLimit: normalized.limits.steps }) + } + ] + }) + ) + } + + if (config.bindings?.pipelines) { + mfConfig.pipelines = Object.fromEntries( + Object.entries(config.bindings.pipelines).map(([bindingName, binding]) => { + const normalized = normalizePipelineBinding(binding) + return [ + bindingName, + typeof binding === 'string' + ? normalized.pipeline + : { pipeline: normalized.pipeline } + ] + }) + ) + } + + if (config.bindings?.images && localBindingShimServiceConfig.localBindingNames.length === 0) { + const [entry] = Object.entries(config.bindings.images) + if (entry) { + const [bindingName, binding] = entry + const normalized = normalizeImagesBinding(bindingName, binding) + mfConfig.images = { + binding: normalized.binding + } + } + } + + if (config.bindings?.media && localBindingShimServiceConfig.localBindingNames.length === 0) { + const [entry] = Object.entries(config.bindings.media) + if (entry) { + const [bindingName, binding] = entry + const normalized = normalizeMediaBinding(bindingName, binding) + mfConfig.media = { + binding: normalized.binding + } + } + } + + if (config.bindings?.artifacts) { + mfConfig.artifacts = Object.fromEntries( + Object.entries(config.bindings.artifacts).map(([bindingName, binding]) => { + const normalized = normalizeArtifactsBinding(binding) + return [ + bindingName, + { + namespace: normalized.namespace + } + ] + }) + ) + } + + if (config.bindings?.aiSearchNamespaces) { + mfConfig.aiSearchNamespaces = Object.fromEntries( + Object.entries(config.bindings.aiSearchNamespaces).map(([bindingName, binding]) => [ + bindingName, + { + namespace: binding.namespace + } + ]) + ) + } + + if (config.bindings?.aiSearch) { + mfConfig.aiSearchInstances = Object.fromEntries( + Object.entries(config.bindings.aiSearch).map(([bindingName, binding]) => [ + bindingName, + { + instance_name: binding.instanceName + } + ]) + ) + } + + if (config.bindings?.secretsStore) { + const secretsStoreEntries = Object.entries(config.bindings.secretsStore).flatMap( + ([bindingName, binding]) => { + if (localSecretBindingNames.has(bindingName)) { + return [] + } + + const normalized = normalizeSecretsStoreBinding(binding, config.secretsStoreId, bindingName) + return [[ + bindingName, + { + store_id: normalized.storeId, + secret_name: normalized.secretName + } + ]] + } + ) + + if (secretsStoreEntries.length > 0) { + mfConfig.secretsStoreSecrets = Object.fromEntries(secretsStoreEntries) + } + } + + const wrappedBindings = { + ...(localSecretWrappedBindingConfig?.wrappedBindings ?? {}) + } + const localBindingWorkers = [ + ...(localSecretWrappedBindingConfig?.workers ?? []), + ...localBindingShimServiceConfig.workers + ] + + if (Object.keys(wrappedBindings).length > 0) { + mfConfig.wrappedBindings = wrappedBindings + } + + if (localBindingShimServiceConfig.localBindingNames.length > 0) { + mfConfig.serviceBindings = { + ...(mfConfig.serviceBindings ?? {}), + ...localBindingShimServiceConfig.serviceBindings + } + } + + if (localBindingWorkers.length > 0) { + mfConfig.__devflareLocalBindingWorkers = localBindingWorkers + } + + if (Object.keys(localWorkerBindings).length > 0) { + mfConfig.bindings = localWorkerBindings + } + + if (config.bindings?.sendEmail) { + mfConfig.email = { + send_email: Object.entries(config.bindings.sendEmail).map(([name, binding]) => ({ + name, + ...(binding.destinationAddress && { + destination_address: binding.destinationAddress + }), + ...(binding.allowedDestinationAddresses && { + allowed_destination_addresses: binding.allowedDestinationAddresses + }), + ...(binding.allowedSenderAddresses && { + allowed_sender_addresses: binding.allowedSenderAddresses + }) + })) + } + } + + return mfConfig +} diff --git a/packages/devflare/src/test/simple-context-multi-worker.ts b/packages/devflare/src/test/simple-context-multi-worker.ts new file mode 100644 index 0000000..fe25f45 --- /dev/null +++ b/packages/devflare/src/test/simple-context-multi-worker.ts @@ -0,0 +1,118 @@ +// ============================================================================= +// Test-context multi-worker Miniflare config builder +// ============================================================================= +// When the user's devflare config declares cross-worker service or DO +// bindings, the test bridge needs Miniflare to spin up a worker-per-target +// instead of running everything inline as the bridge gateway script. +// This pure helper takes the in-progress single-worker `mfConfig`, the +// resolution results from resolve-service-bindings, and the user config, +// and rewrites `mfConfig` in place into a multi-worker layout. +// ============================================================================= + +import type { resolveDOBindings, resolveServiceBindings } from './resolve-service-bindings' +import type { DevflareConfig } from '../config' + +type ServiceBindingResolution = Awaited> +type DOBindingResolution = Awaited> + +/** + * Convert the in-progress single-worker `mfConfig` into a multi-worker + * Miniflare config. Mutates `mfConfig` in place. + * + * The first worker (the "primary") inherits the bridge gateway script and the + * KV/R2/D1/email/DO settings that were set on the top-level mfConfig; + * additional workers come from `serviceBindingResolution.workers` and + * `doBindingResolution.workers`, deduplicated by name. + */ +export function applyMultiWorkerConfig( + mfConfig: any, + config: DevflareConfig, + serviceBindingResolution: ServiceBindingResolution | null, + doBindingResolution: DOBindingResolution | null +): void { + const primaryDurableObjects = { + ...(mfConfig.durableObjects || {}), + ...(doBindingResolution?.crossWorkerDOBindings || {}) + } + + const primaryWorker: Record = { + name: config.name ?? 'primary', + modules: true, + script: mfConfig.script, + compatibilityDate: config.compatibilityDate ?? '2025-01-01', + ...(mfConfig.kvNamespaces && { kvNamespaces: mfConfig.kvNamespaces }), + ...(mfConfig.r2Buckets && { r2Buckets: mfConfig.r2Buckets }), + ...(mfConfig.d1Databases && { d1Databases: mfConfig.d1Databases }), + ...(mfConfig.ratelimits && { ratelimits: mfConfig.ratelimits }), + ...(mfConfig.versionMetadata && { versionMetadata: mfConfig.versionMetadata }), + ...(mfConfig.workerLoaders && { workerLoaders: mfConfig.workerLoaders }), + ...(mfConfig.mtlsCertificates && { mtlsCertificates: mfConfig.mtlsCertificates }), + ...(mfConfig.dispatchNamespaces && { dispatchNamespaces: mfConfig.dispatchNamespaces }), + ...(mfConfig.workflows && { workflows: mfConfig.workflows }), + ...(mfConfig.pipelines && { pipelines: mfConfig.pipelines }), + ...(mfConfig.images && { images: mfConfig.images }), + ...(mfConfig.media && { media: mfConfig.media }), + ...(mfConfig.artifacts && { artifacts: mfConfig.artifacts }), + ...(mfConfig.secretsStoreSecrets && { secretsStoreSecrets: mfConfig.secretsStoreSecrets }), + ...(mfConfig.wrappedBindings && { wrappedBindings: mfConfig.wrappedBindings }), + ...(mfConfig.email && { email: mfConfig.email }), + ...(Object.keys(primaryDurableObjects).length > 0 && { durableObjects: primaryDurableObjects }), + ...( + mfConfig.serviceBindings || serviceBindingResolution?.primaryServiceBindings + ? { + serviceBindings: { + ...(mfConfig.serviceBindings ?? {}), + ...(serviceBindingResolution?.primaryServiceBindings ?? {}) + } + } + : {} + ) + } + + const additionalWorkers = [ + ...(serviceBindingResolution?.workers || []), + ...(doBindingResolution?.workers || []), + ...(mfConfig.__devflareLocalSecretWorkers || []), + ...(mfConfig.__devflareLocalBindingWorkers || []) + ] + const workersByName = new Map() + + for (const worker of additionalWorkers) { + if (!workersByName.has(worker.name)) { + workersByName.set(worker.name, worker) + continue + } + + const existing = workersByName.get(worker.name)! + if (worker.durableObjects) { + existing.durableObjects = { + ...(existing.durableObjects || {}), + ...worker.durableObjects + } + } + } + + const workers = [primaryWorker, ...workersByName.values()] + delete mfConfig.script + delete mfConfig.modules + delete mfConfig.kvNamespaces + delete mfConfig.r2Buckets + delete mfConfig.d1Databases + delete mfConfig.ratelimits + delete mfConfig.versionMetadata + delete mfConfig.workerLoaders + delete mfConfig.mtlsCertificates + delete mfConfig.dispatchNamespaces + delete mfConfig.workflows + delete mfConfig.pipelines + delete mfConfig.images + delete mfConfig.media + delete mfConfig.artifacts + delete mfConfig.secretsStoreSecrets + delete mfConfig.wrappedBindings + delete mfConfig.serviceBindings + delete mfConfig.__devflareLocalSecretWorkers + delete mfConfig.__devflareLocalBindingWorkers + delete mfConfig.durableObjects + mfConfig.workers = workers +} diff --git a/packages/devflare/src/test/simple-context-paths.ts b/packages/devflare/src/test/simple-context-paths.ts new file mode 100644 index 0000000..b583b32 --- /dev/null +++ b/packages/devflare/src/test/simple-context-paths.ts @@ -0,0 +1,176 @@ +import { existsSync } from 'fs' +import { createServer } from 'net' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' +import { resolveConfigPath } from '../config' + +export const DEFAULT_TRANSPORT_ENTRY_FILES = [ + 'src/transport.ts', + 'src/transport.js', + 'src/transport.mts', + 'src/transport.mjs' +] as const + +const CURRENT_PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url))) + +/** + * Access Bun global via globalThis to avoid shadowing richer @types/bun + * when available. Returns undefined if not running in Bun. + */ +export function getBunRuntime(): { + main: string + build: (options: { + entrypoints: string[] + target: string + format: string + minify: boolean + external?: string[] + }) => Promise<{ + success: boolean + logs: string[] + outputs: Array<{ path: string; text: () => Promise }> + }> +} | undefined { + const g = globalThis as { Bun?: unknown } + if (typeof g.Bun === 'object' && g.Bun !== null) { + return g.Bun as ReturnType + } + + return undefined +} + +/** + * Get the directory of the test file. + * Prefers stack trace parsing so bun test hooks resolve the actual test file, + * then falls back to the current working directory. + * + * We intentionally do not use Bun.main here because workspace consumers often + * import the built devflare package from `packages/devflare/dist`, which would + * incorrectly anchor autodiscovery inside the devflare package instead of the + * calling project under test. + */ +export function getCallerDirectory(): string { + const stackCallerDirectory = getStackCallerDirectory() + if (stackCallerDirectory) { + return stackCallerDirectory + } + + return process.cwd() +} + +function getStackCallerDirectory(): string | null { + const originalPrepare = Error.prepareStackTrace + Error.prepareStackTrace = (_, stack) => stack + + try { + const err = new Error() + const stack = err.stack as unknown as NodeJS.CallSite[] | undefined + + for (const site of stack ?? []) { + const filename = site.getFileName?.() + if ( + filename + && !isInsideCurrentPackage(filename) + && !filename.includes('simple-context') + && !filename.includes('node_modules') + && !filename.includes('[') + && existsSync(filename) + ) { + return dirname(filename) + } + } + } finally { + Error.prepareStackTrace = originalPrepare + } + + return null +} + +function findPackageRoot(startDir: string): string { + let currentDir = startDir + + while (true) { + if (existsSync(join(currentDir, 'package.json'))) { + return currentDir + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) { + return startDir + } + + currentDir = parentDir + } +} + +function isInsideCurrentPackage(filePath: string): boolean { + const normalizedFilePath = filePath.replace(/\\/g, '/') + const normalizedPackageRoot = CURRENT_PACKAGE_ROOT.replace(/\\/g, '/') + + return normalizedFilePath === normalizedPackageRoot + || normalizedFilePath.startsWith(`${normalizedPackageRoot}/`) +} + +/** + * Find the nearest supported devflare config by searching upward from startDir. + */ +export async function findNearestConfig(startDir: string): Promise { + let currentDir = startDir + + while (true) { + const configPath = await resolveConfigPath(currentDir) + if (configPath) { + return configPath + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) { + return null + } + + currentDir = parentDir + } +} + +export async function getAvailablePort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer() + + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Could not determine an available port'))) + return + } + + const { port } = address + server.close((error) => { + if (error) { + reject(error) + return + } + + resolvePort(port) + }) + }) + }) +} + +export function resolveTransportFile(configDir: string, configuredPath: string | null | undefined): string | null { + if (typeof configuredPath === 'string') { + return configuredPath + } + + if (configuredPath === null) { + return null + } + + for (const defaultEntry of DEFAULT_TRANSPORT_ENTRY_FILES) { + if (existsSync(join(configDir, defaultEntry))) { + return defaultEntry + } + } + + return null +} diff --git a/packages/devflare/src/test/simple-context-runtime.ts b/packages/devflare/src/test/simple-context-runtime.ts new file mode 100644 index 0000000..fefb99f --- /dev/null +++ b/packages/devflare/src/test/simple-context-runtime.ts @@ -0,0 +1,55 @@ +// ============================================================================= +// Test context โ€” runtime boot phase +// ============================================================================= +// Selects between the multi-worker Miniflare path and the bridge-backed path +// and returns the assembled runtime handles. Extracted from +// `createTestContext()` so the main function reads as a sequence of +// well-named lifecycle steps (assemble config โ†’ boot runtime โ†’ wire env). +// ============================================================================= + +import { wrapEnvSendEmailBindings } from '../utils/send-email' +import type { BridgeClient } from '../bridge/client' +import { getAvailablePort } from './simple-context-paths' +import { startBridgeBackedTestContext } from './simple-context-startup' + +export interface BootedTestRuntime { + activePort: number + miniflare: any + miniflareBindings: Record + /** Only present in the bridge-backed path. */ + client: BridgeClient | null +} + +/** + * Boot the Miniflare runtime that backs `createTestContext()`. + * + * Two paths: + * - **multi-worker**: when the caller has cross-worker DOs or service + * bindings, spin up Miniflare directly on a free port. No bridge client. + * - **bridge-backed**: otherwise, defer to `startBridgeBackedTestContext()`, + * which also returns a connected `BridgeClient`. + */ +export async function bootTestRuntime( + mfConfig: any, + usesMultiWorker: boolean +): Promise { + if (usesMultiWorker) { + const { Miniflare } = await import('miniflare') + const activePort = await getAvailablePort() + const miniflare = new Miniflare({ + ...mfConfig, + port: activePort + }) + await miniflare.ready + const miniflareBindings = wrapEnvSendEmailBindings(await miniflare.getBindings()) + return { activePort, miniflare, miniflareBindings, client: null } + } + + const started = await startBridgeBackedTestContext(mfConfig) + return { + activePort: started.port, + miniflare: started.miniflare, + miniflareBindings: started.miniflareBindings, + client: started.client + } +} diff --git a/packages/devflare/src/test/simple-context-startup.ts b/packages/devflare/src/test/simple-context-startup.ts new file mode 100644 index 0000000..04a2dba --- /dev/null +++ b/packages/devflare/src/test/simple-context-startup.ts @@ -0,0 +1,166 @@ +// ============================================================================= +// Bridge-backed test context startup +// ============================================================================= +// Self-contained retry helpers for spinning up a Miniflare instance and a +// BridgeClient against a randomly assigned port. Extracted from +// simple-context.ts to keep the main createTestContext flow readable. +// ============================================================================= + +import { BridgeClient } from '../bridge/client' +import { wrapEnvSendEmailBindings } from '../utils/send-email' +import { getAvailablePort } from './simple-context-paths' + +const TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS = 3 +const TEST_CONTEXT_STARTUP_RETRY_DELAY_MS = 75 +const TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS = 8 +const TEST_CONTEXT_BRIDGE_CONNECT_RETRY_DELAY_MS = 150 + +export interface StartedBridgeBackedTestContext { + port: number + client: BridgeClient + miniflare: any + miniflareBindings: Record +} + +export function isRetriableTestContextStartupError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const message = error.message.toLowerCase() + return message.includes('websocket connection failed') + || message.includes('connection timeout: ws://') + || message.includes('econnrefused') + || message.includes('eaddrinuse') + || message.includes('address already in use') +} + +async function waitForTestContextStartupRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, TEST_CONTEXT_STARTUP_RETRY_DELAY_MS)) +} + +async function waitForBridgeClientRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, TEST_CONTEXT_BRIDGE_CONNECT_RETRY_DELAY_MS)) +} + +export async function connectBridgeClientWithRetry(url: string): Promise { + let lastError: unknown + + for (let attempt = 1;attempt <= TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS;attempt++) { + const client = new BridgeClient({ url }) + + try { + await client.connect() + return client + } catch (error) { + lastError = error + client.disconnect() + + if ( + attempt >= TEST_CONTEXT_BRIDGE_CONNECT_RETRY_ATTEMPTS + || !isRetriableTestContextStartupError(error) + ) { + throw error + } + + await waitForBridgeClientRetry() + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Bridge-backed test context could not connect to the WebSocket gateway.') +} + +function expandLocalBindingWorkers(mfConfig: any): any { + const auxiliaryWorkers = [ + ...(mfConfig.__devflareLocalSecretWorkers ?? []), + ...(mfConfig.__devflareLocalBindingWorkers ?? []) + ] + if (!Array.isArray(auxiliaryWorkers) || auxiliaryWorkers.length === 0) { + return mfConfig + } + + const { + __devflareLocalSecretWorkers, + __devflareLocalBindingWorkers, + port, + host, + log, + kvPersist, + r2Persist, + d1Persist, + durableObjectsPersist, + workflowsPersist, + imagesPersist, + ...primaryWorker + } = mfConfig + const primaryWorkerName = typeof primaryWorker.name === 'string' + ? primaryWorker.name + : 'primary' + + return { + ...(port !== undefined && { port }), + ...(host && { host }), + ...(log && { log }), + ...(kvPersist && { kvPersist }), + ...(r2Persist && { r2Persist }), + ...(d1Persist && { d1Persist }), + ...(durableObjectsPersist && { durableObjectsPersist }), + ...(workflowsPersist && { workflowsPersist }), + ...(imagesPersist && { imagesPersist }), + workers: [ + { + ...primaryWorker, + name: primaryWorkerName + }, + ...auxiliaryWorkers + ] + } +} + +export async function startBridgeBackedTestContext(mfConfig: any): Promise { + const { Miniflare } = await import('miniflare') + + for (let attempt = 1;attempt <= TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS;attempt++) { + const port = await getAvailablePort() + let miniflare: any = null + let client: BridgeClient | null = null + + try { + miniflare = new Miniflare(expandLocalBindingWorkers({ + ...mfConfig, + port + })) + await miniflare.ready + + const miniflareBindings = wrapEnvSendEmailBindings(await miniflare.getBindings()) + client = await connectBridgeClientWithRetry(`ws://localhost:${port}`) + + return { + port, + client, + miniflare, + miniflareBindings + } + } catch (error) { + client?.disconnect() + + if (miniflare) { + try { + await miniflare.dispose() + } catch { + // Ignore cleanup failures while retrying test context startup. + } + } + + if (attempt >= TEST_CONTEXT_STARTUP_RETRY_ATTEMPTS || !isRetriableTestContextStartupError(error)) { + throw error + } + + await waitForTestContextStartupRetry() + } + } + + throw new Error('Bridge-backed test context startup exhausted all retry attempts.') +} diff --git a/packages/devflare/src/test/simple-context-transport.ts b/packages/devflare/src/test/simple-context-transport.ts new file mode 100644 index 0000000..b0d2f3e --- /dev/null +++ b/packages/devflare/src/test/simple-context-transport.ts @@ -0,0 +1,73 @@ +// ============================================================================= +// Test-context transport encoding/decoding helpers +// ============================================================================= +// Loads the user-defined transport module (if any) and builds a recursive +// `decode` function that walks an arbitrary value, looking for the +// `__transport` envelope produced by the bridge worker and applying the +// matching user decoder. +// ============================================================================= + +import { join } from 'path' + +export type TransportDecoderMap = Map unknown> + +/** + * Load the user's transport module from disk and build a name -> decoder map. + * Returns `null` if the module exists but does not export a `transport` object + * (a warning is logged in that case). + */ +export async function loadTransportDecoders( + configDir: string, + transportFile: string +): Promise { + const transportPath = join(configDir, transportFile) + const transportModule = await import(transportPath) + + if (!transportModule.transport) { + console.warn( + `[devflare] Warning: Transport file "${transportFile}" does not export a named "transport" object.\n` + + `Expected: export const transport = { ... }\n` + + `Transport encoding/decoding will be disabled.` + ) + return null + } + + const decoders: TransportDecoderMap = new Map() + for (const [typeName, transporter] of Object.entries(transportModule.transport)) { + const t = transporter as { encode: (v: unknown) => unknown; decode: (v: unknown) => unknown } + decoders.set(typeName, t.decode) + } + return decoders +} + +/** + * Recursively walk `value`, replacing `{ __transport, value }` envelopes with + * the result of the registered decoder. Returns `value` unchanged when + * `decoders` is `null` or no envelope is present. + */ +export function decodeTransportValue( + decoders: TransportDecoderMap | null, + value: unknown +): unknown { + if (!decoders || value === null || typeof value !== 'object') { + return value + } + + if ('__transport' in (value as Record)) { + const encoded = value as { __transport: string; value: unknown } + const decoder = decoders.get(encoded.__transport) + if (decoder) { + return decoder(encoded.value) + } + } + + if (Array.isArray(value)) { + return value.map((item) => decodeTransportValue(decoders, item)) + } + + const result: Record = {} + for (const [k, v] of Object.entries(value)) { + result[k] = decodeTransportValue(decoders, v) + } + return result +} diff --git a/packages/devflare/src/test/simple-context.ts b/packages/devflare/src/test/simple-context.ts new file mode 100644 index 0000000..bc4158d --- /dev/null +++ b/packages/devflare/src/test/simple-context.ts @@ -0,0 +1,199 @@ +// ============================================================================= +// Simple Test Context โ€” The user-friendly API +// ============================================================================= +// Usage: +// import { createTestContext } from 'devflare/test' +// import { env } from 'devflare' +// beforeAll(() => createTestContext()) // Auto-finds the nearest devflare config +// afterAll(() => env.dispose()) +// test('works', async () => { +// const result = await env.MY_DO.getByName('main').getValue() +// }) +// ============================================================================= + +import { BridgeClient } from '../bridge/client' +import { createEnvProxy, setBindingHints, type BindingHints } from '../bridge/proxy' +import { __setTestContext } from '../env' +import { hasCrossWorkerDOs, hasServiceBindings, resolveDOBindings, resolveServiceBindings } from './resolve-service-bindings' +import { buildDurableObjectGateway } from './simple-context-durable-objects' +import { resolveTransportFile } from './simple-context-paths' +import { extractBindingHints } from './binding-hints' +import { buildRemoteAndStaticBindings } from './simple-context-bindings' +import { configureSurfaceHandlers, createBridgeEnvAccessor, createMultiWorkerEnvAccessor } from './simple-context-env' +import { resolveHandlerPaths } from './simple-context-handlers' +import { createDisposeContext, resolveTestContextConfig } from './simple-context-lifecycle' +import { bootTestRuntime } from './simple-context-runtime' +import { decodeTransportValue, loadTransportDecoders, type TransportDecoderMap } from './simple-context-transport' +import { applyMultiWorkerConfig } from './simple-context-multi-worker' +import { buildInlineBridgeMfConfig } from './simple-context-mfconfig' + +// Handler helper configuration +// ----------------------------------------------------------------------------- +// Per-context state +// ----------------------------------------------------------------------------- + +interface TestContextState { + client: BridgeClient | null + miniflare: any + envProxy: Record | null + transportDecode: TransportDecoderMap | null + remoteBindings: Record | null + miniflareBindings: Record | null +} + +function createTestContextState(): TestContextState { + return { + client: null, + miniflare: null, + envProxy: null, + transportDecode: null, + remoteBindings: null, + miniflareBindings: null + } +} + +function shouldPreferBridgeBinding(hint: BindingHints[string] | undefined): boolean { + return hint === 'do' || hint === 'service' +} + +// ----------------------------------------------------------------------------- +// Main API +// ----------------------------------------------------------------------------- + +/** + * Create a test context from a devflare config file. + * This starts Miniflare with the configured bindings and sets up the bridge. + * + * @param configPath - Optional path to config file. If not provided, searches + * upward from the test file for a supported devflare config. + */ +export async function createTestContext(configPath?: string): Promise { + const state = createTestContextState() + const { configDir, config } = await resolveTestContextConfig(configPath) + + state.remoteBindings = buildRemoteAndStaticBindings(config) + + const hints = extractBindingHints(config) + + const decodeTransport = (value: unknown): unknown => decodeTransportValue(state.transportDecode, value) + + const needsMultiWorkerForServices = hasServiceBindings(config) + const needsMultiWorkerForDOs = hasCrossWorkerDOs(config) + const needsMultiWorker = needsMultiWorkerForServices || needsMultiWorkerForDOs + + let serviceBindingResolution: Awaited> | null = null + let doBindingResolution: Awaited> | null = null + + if (needsMultiWorkerForServices) { + serviceBindingResolution = await resolveServiceBindings(config, configDir) + } + if (needsMultiWorkerForDOs) { + doBindingResolution = await resolveDOBindings(config, configDir) + } + + const mfConfig: any = buildInlineBridgeMfConfig(config, { cwd: configDir }) + + const transportFile = resolveTransportFile(configDir, config.files?.transport) + + if (transportFile) { + state.transportDecode = await loadTransportDecoders(configDir, transportFile) + } + + const gateway = await buildDurableObjectGateway(config, configDir, transportFile) + mfConfig.script = gateway.script + if (gateway.durableObjects) { + mfConfig.durableObjects = gateway.durableObjects + } + + const hasMultiWorkerServices = serviceBindingResolution && serviceBindingResolution.workers.length > 0 + const hasMultiWorkerDOs = doBindingResolution && doBindingResolution.workers.length > 0 + + if (hasMultiWorkerServices || hasMultiWorkerDOs) { + applyMultiWorkerConfig(mfConfig, config, serviceBindingResolution, doBindingResolution) + } + + const usesMultiWorker = Boolean(hasMultiWorkerServices || hasMultiWorkerDOs) + const runtime = await bootTestRuntime(mfConfig, usesMultiWorker) + const activePort = runtime.activePort + state.miniflare = runtime.miniflare + state.miniflareBindings = runtime.miniflareBindings + state.client = runtime.client + + const disposeContext = createDisposeContext(state) + + const getTestEnv = (): Record => { + return new Proxy({}, { + get(_, prop: string) { + if (state.remoteBindings && prop in state.remoteBindings) { + return state.remoteBindings[prop] + } + if (hints[prop] === 'sendEmail' && state.envProxy && prop in state.envProxy) { + return state.envProxy[prop] + } + if (state.miniflareBindings && prop in state.miniflareBindings) { + return state.miniflareBindings[prop] + } + if (state.envProxy && prop in state.envProxy) { + return state.envProxy[prop] + } + return undefined + }, + has(_, prop: string) { + return Boolean( + (state.remoteBindings && prop in state.remoteBindings) + || (state.miniflareBindings && prop in state.miniflareBindings) + || (state.envProxy && prop in state.envProxy) + ) + } + }) as Record + } + + const handlerPaths = await resolveHandlerPaths(configDir, config) + + configureSurfaceHandlers({ + handlerPaths, + configDir, + activePort, + getEnv: getTestEnv + }) + + if (usesMultiWorker) { + setBindingHints(hints) + __setTestContext(createMultiWorkerEnvAccessor(state), disposeContext) + return + } + + const bridgeClient = state.client + if (!bridgeClient) { + throw new Error('Bridge-backed test context did not initialize a client.') + } + + setBindingHints(hints) + state.envProxy = createEnvProxy({ + client: bridgeClient, + transformResult: (result: unknown) => decodeTransport(result) + }) + + __setTestContext( + createBridgeEnvAccessor(state, hints, shouldPreferBridgeBinding), + disposeContext + ) +} + +/** + * Test environment interface - extend this in your project's env.d.ts + */ +export interface TestEnv { + dispose(): Promise +} + +/** + * Base environment type โ€” alias for the global `DevflareEnv` interface that + * users augment via their project's `env.d.ts`. Re-exported from + * `devflare/test` so consumers can write `const e: DevflareEnv = ...` against + * their own augmented bindings without importing from a different module than + * the rest of the test API. + */ +export type DevflareEnv = globalThis.DevflareEnv + +export { env } from '../env' diff --git a/packages/devflare/src/test/tail.ts b/packages/devflare/src/test/tail.ts new file mode 100644 index 0000000..3fe5e91 --- /dev/null +++ b/packages/devflare/src/test/tail.ts @@ -0,0 +1,253 @@ +// ============================================================================= +// Tail Test Helper โ€” Trigger tail handlers in Bun tests +// ============================================================================= +// Usage: +// import { cf } from 'devflare/test' +// +// // Trigger the tail handler with trace items +// // createTestContext() auto-detects src/tail.ts when present. +// await cf.tail.trigger([ +// { +// scriptName: 'my-worker', +// outcome: 'ok', +// eventTimestamp: Date.now(), +// logs: [{ level: 'log', message: ['Hello'], timestamp: Date.now() }] +// } +// ]) +// ============================================================================= + +import type { TraceItem, TraceLog, TraceException } from '@cloudflare/workers-types' +import { join } from 'path' +import { createTailEvent, runWithEventContext } from '../runtime' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface TraceItemOptions { + /** Name of the worker that produced this trace */ + scriptName?: string + /** Outcome of the event */ + outcome?: 'ok' | 'exception' | 'exceededCpu' | 'exceededMemory' | 'unknown' + /** Timestamp of the event */ + eventTimestamp?: number + /** Logs produced during the event */ + logs?: TraceLog[] + /** Exceptions thrown during the event */ + exceptions?: TraceException[] + /** Event details (request, scheduled, etc.) */ + event?: TraceItem['event'] + /** Script version info */ + scriptVersion?: { id: string } + /** Dispatch namespace (for namespaced workers) */ + dispatchNamespace?: string + /** Script tags */ + scriptTags?: string[] + /** Diagnostics channel events */ + diagnosticsChannelEvents?: unknown[] +} + +export interface TailTriggerResult { + /** Whether the handler completed successfully */ + success: boolean + /** Error message if handler threw */ + error?: string + /** Number of trace items processed */ + itemCount: number +} + +type TailHandler = (events: TraceItem[] | ReturnType, env?: Record, ctx?: ExecutionContext) => unknown + +// ----------------------------------------------------------------------------- +// Global State (set by createTestContext) +// ----------------------------------------------------------------------------- + +let tailHandlerPath: string | null = null +let configDir: string | null = null +let testEnvGetter: (() => Record) | null = null + +// ----------------------------------------------------------------------------- +// Configuration (called by createTestContext) +// ----------------------------------------------------------------------------- + +/** + * Configure the tail test helper + * @internal Called by createTestContext to set up handler path and env + */ +export function configureTail(options: { + handlerPath: string | null + configDir: string + getEnv: () => Record +}): void { + tailHandlerPath = options.handlerPath + configDir = options.configDir + testEnvGetter = options.getEnv +} + +/** + * Reset tail helper state + * @internal Called when test context is disposed + */ +export function resetTailState(): void { + tailHandlerPath = null + configDir = null + testEnvGetter = null +} + +// ----------------------------------------------------------------------------- +// Trace Item Builder +// ----------------------------------------------------------------------------- + +/** + * Create a complete TraceItem from options + */ +function createTraceItem(options: TraceItemOptions): TraceItem { + return { + scriptName: options.scriptName ?? 'test-worker', + outcome: options.outcome ?? 'ok', + eventTimestamp: options.eventTimestamp ?? Date.now(), + event: options.event ?? { + request: { + url: 'https://example.com/', + method: 'GET' + } + }, + logs: options.logs ?? [], + exceptions: options.exceptions ?? [], + diagnosticsChannelEvents: options.diagnosticsChannelEvents ?? [], + scriptVersion: options.scriptVersion ?? { id: 'test-version' }, + dispatchNamespace: options.dispatchNamespace, + scriptTags: options.scriptTags ?? [] + } as TraceItem +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Trigger the tail handler with trace items. + * This directly invokes the auto-detected `src/tail.ts` handler, or another + * tail handler that has already been configured internally. + * + * @param items - Array of trace items or trace item options + * @returns Result object with success status + * + * @example + * ```ts + * // Trigger with full trace items + * await cf.tail.trigger([ + * { + * scriptName: 'my-worker', + * outcome: 'ok', + * logs: [{ level: 'log', message: ['Request processed'], timestamp: Date.now() }] + * } + * ]) + * + * // Trigger with minimal options (defaults applied) + * await cf.tail.trigger([ + * { logs: [{ level: 'error', message: ['Something failed'], timestamp: Date.now() }] } + * ]) + * ``` + */ +async function trigger( + items: Array +): Promise { + if (!tailHandlerPath) { + throw new Error( + 'Tail handler not configured. Add a src/tail.ts file exporting tail(), ' + + 'or configure a tail handler before calling cf.tail.trigger().' + ) + } + + if (!configDir || !testEnvGetter) { + throw new Error( + 'Tail helper not initialized. Call createTestContext() before using cf.tail.trigger()' + ) + } + + // Normalize trace items + const traceItems = items.map((item) => { + // Check if it's already a complete TraceItem (has required fields) + if ('eventTimestamp' in item && 'outcome' in item && 'scriptName' in item) { + return item as TraceItem + } + return createTraceItem(item as TraceItemOptions) + }) + + // Import the tail handler + const absolutePath = join(configDir, tailHandlerPath) + const handlerModule = await import(absolutePath) + + // Get the tail handler function from default function, default object, or named export. + const defaultExport = handlerModule.default + const tailHandler = typeof defaultExport === 'function' + ? defaultExport + : defaultExport && typeof defaultExport.tail === 'function' + ? defaultExport.tail.bind(defaultExport) + : handlerModule.tail + if (typeof tailHandler !== 'function') { + throw new Error( + `Tail handler at "${tailHandlerPath}" must export a default function or named "tail" export.\n` + + + `Expected: export async function tail(event) { ... } or export default { tail(events, env, ctx) { ... } }` + ) + } + + // Create execution context + const waitUntilPromises: Promise[] = [] + const ctx: ExecutionContext = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { }, + props: {} + } + + // Get the test env + const env = testEnvGetter() + const tailEvent = createTailEvent(traceItems, env, ctx) + + try { + // Call the handler + await runWithEventContext( + tailEvent, + () => (tailHandler as TailHandler).length >= 2 + ? tailHandler(traceItems, env, ctx) + : tailHandler(tailEvent, env, ctx) + ) + + // Wait for all waitUntil promises + await Promise.all(waitUntilPromises) + + return { + success: true, + itemCount: traceItems.length + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + itemCount: traceItems.length + } + } +} + +/** + * Create a TraceItem with sensible defaults. + * Useful for building test data. + * + * @param options - Partial trace item options + * @returns Complete TraceItem + */ +function create(options: TraceItemOptions = {}): TraceItem { + return createTraceItem(options) +} + +// ----------------------------------------------------------------------------- +// Export +// ----------------------------------------------------------------------------- + +export const tail = { + trigger, + create +} diff --git a/packages/devflare/src/test/utilities.ts b/packages/devflare/src/test/utilities.ts new file mode 100644 index 0000000..81075aa --- /dev/null +++ b/packages/devflare/src/test/utilities.ts @@ -0,0 +1,10 @@ +export * from './utilities/context' +export * from './utilities/kv' +export * from './utilities/d1' +export * from './utilities/r2' +export * from './utilities/queue' +export * from './utilities/platform' +export * from './utilities/workflows' +export * from './utilities/media' +export * from './utilities/artifacts' +export * from './utilities/env' diff --git a/packages/devflare/src/test/utilities/artifacts.ts b/packages/devflare/src/test/utilities/artifacts.ts new file mode 100644 index 0000000..542e998 --- /dev/null +++ b/packages/devflare/src/test/utilities/artifacts.ts @@ -0,0 +1,193 @@ +// ============================================================================= +// Mock Artifacts +// ============================================================================= + +export interface MockArtifactsOptions { + repos?: Array & { name: string }> +} + +function createArtifactTimestamp(): string { + return new Date('2026-04-26T00:00:00.000Z').toISOString() +} + +function createArtifactsRepoInfo( + name: string, + options: { + description?: string | null + readOnly?: boolean + defaultBranch?: string + source?: string | null + } = {} +): ArtifactsRepoInfo { + const now = createArtifactTimestamp() + return { + id: `repo-${name}`, + name, + description: options.description ?? null, + defaultBranch: options.defaultBranch ?? 'main', + createdAt: now, + updatedAt: now, + lastPushAt: null, + source: options.source ?? null, + readOnly: options.readOnly ?? false, + remote: `https://example.com/artifacts/default/${name}.git` + } +} + +export function isArtifactsBinding(value: MockArtifactsOptions | Artifacts): value is Artifacts { + return typeof (value as { create?: unknown }).create === 'function' +} + +/** + * Creates an in-memory Artifacts binding for pure unit tests. + */ +export function createMockArtifacts(options: MockArtifactsOptions = {}): Artifacts { + const repos = new Map() + const tokens = new Map() + + const addRepo = (info: ArtifactsRepoInfo) => { + repos.set(info.name, info) + if (!tokens.has(info.name)) { + tokens.set(info.name, []) + } + } + + for (const repo of options.repos ?? []) { + addRepo({ + ...createArtifactsRepoInfo(repo.name), + ...repo + }) + } + + const createToken = ( + repoName: string, + scope: 'write' | 'read' = 'write', + ttl = 86400 + ): ArtifactsCreateTokenResult => { + const existing = tokens.get(repoName) ?? [] + const id = `token-${repoName}-${existing.length + 1}` + const expiresAt = new Date(Date.parse(createArtifactTimestamp()) + ttl * 1000).toISOString() + const token: ArtifactsTokenInfo = { + id, + scope, + state: 'active', + createdAt: createArtifactTimestamp(), + expiresAt + } + tokens.set(repoName, [...existing, token]) + return { + id, + plaintext: `${id}-plaintext`, + scope, + expiresAt + } + } + + const createRepoHandle = (info: ArtifactsRepoInfo): ArtifactsRepo => + ({ + ...info, + async createToken( + scope?: 'write' | 'read', + ttl?: number + ): Promise { + return createToken(info.name, scope, ttl) + }, + async listTokens(): Promise { + const repoTokens = tokens.get(info.name) ?? [] + return { + tokens: repoTokens, + total: repoTokens.length + } + }, + async revokeToken(tokenOrId: string): Promise { + const repoTokens = tokens.get(info.name) ?? [] + const index = repoTokens.findIndex((token) => token.id === tokenOrId) + if (index === -1) { + return false + } + + repoTokens[index] = { + ...repoTokens[index], + state: 'revoked' + } + tokens.set(info.name, repoTokens) + return true + }, + async fork( + name: string, + forkOptions?: { description?: string; readOnly?: boolean; defaultBranchOnly?: boolean } + ): Promise { + return createRepo(name, { + description: forkOptions?.description ?? info.description ?? undefined, + readOnly: forkOptions?.readOnly ?? info.readOnly, + setDefaultBranch: info.defaultBranch, + source: `artifacts:default/${info.name}` + }) + } + }) as ArtifactsRepo + + const createRepo = async ( + name: string, + createOptions: { + readOnly?: boolean + description?: string + setDefaultBranch?: string + source?: string | null + } = {} + ): Promise => { + const info = createArtifactsRepoInfo(name, { + description: createOptions.description, + readOnly: createOptions.readOnly, + defaultBranch: createOptions.setDefaultBranch, + source: createOptions.source + }) + addRepo(info) + const token = createToken(name) + return { + id: info.id, + name: info.name, + description: info.description, + defaultBranch: info.defaultBranch, + remote: info.remote, + token: token.plaintext, + tokenExpiresAt: token.expiresAt + } + } + + return { + create: createRepo, + async get(name: string): Promise { + const repo = repos.get(name) + return repo ? createRepoHandle(repo) : null + }, + async import(params: { + source: { url: string; branch?: string; depth?: number } + target: { name: string; opts?: { description?: string; readOnly?: boolean } } + }): Promise { + return createRepo(params.target.name, { + description: params.target.opts?.description, + readOnly: params.target.opts?.readOnly, + source: params.source.url + }) + }, + async list(opts?: { limit?: number; cursor?: string }): Promise { + const limit = opts?.limit ?? 50 + const repoList = Array.from(repos.values()) + .slice(0, limit) + .map((repo) => { + const { remote: _remote, ...rest } = repo + return rest + }) + + return { + repos: repoList, + total: repos.size, + ...(repos.size > repoList.length && { cursor: String(repoList.length) }) + } + }, + async delete(name: string): Promise { + tokens.delete(name) + return repos.delete(name) + } + } as unknown as Artifacts +} diff --git a/packages/devflare/src/test/utilities/context.ts b/packages/devflare/src/test/utilities/context.ts new file mode 100644 index 0000000..9fb7dda --- /dev/null +++ b/packages/devflare/src/test/utilities/context.ts @@ -0,0 +1,85 @@ +import { type RequestContext, runWithContext } from '../../runtime/context' + +// ============================================================================= +// Types +// ============================================================================= + +export interface TestContextOptions> { + env?: TEnv + request?: Request | null + type?: 'fetch' | 'scheduled' | 'queue' | 'email' | 'tail' +} + +export interface TestContext> { + env: TEnv + ctx: ExecutionContext + request: Request | null + waitUntilPromises: Promise[] +} + +// ============================================================================= +// Test Context +// ============================================================================= + +/** + * Creates a test context with mock ExecutionContext + * + * @example + * ```ts + * const ctx = createTestContext({ + * env: { API_KEY: 'test' }, + * request: new Request('https://test.com') + * }) + * ``` + */ +export function createMockTestContext>( + options: TestContextOptions = {} +): TestContext { + const waitUntilPromises: Promise[] = [] + + const ctx = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { + // No-op in tests + }, + props: {} + } as ExecutionContext + + return { + env: (options.env ?? {}) as TEnv, + ctx, + request: options.request ?? null, + waitUntilPromises + } +} + +/** + * Runs a function within a test context + * + * @example + * ```ts + * const response = await withTestContext( + * { env: { DB: mockD1 } }, + * async () => { + * // env, ctx, locals all work here + * return handler.fetch(new Request('https://test.com')) + * } + * ) + * ``` + */ +export async function withTestContext>( + options: TestContextOptions, + handler: () => Promise +): Promise { + const testCtx = createMockTestContext(options) + + return runWithContext( + testCtx.env as Record, + testCtx.ctx, + options.request ?? null, + handler, + options.type ?? 'fetch' + ) +} diff --git a/packages/devflare/src/test/utilities/d1.ts b/packages/devflare/src/test/utilities/d1.ts new file mode 100644 index 0000000..760b6f6 --- /dev/null +++ b/packages/devflare/src/test/utilities/d1.ts @@ -0,0 +1,168 @@ +// ============================================================================= +// Mock D1 +// ============================================================================= + +interface D1Result { + results: T[] + success: boolean + meta: { duration: number; changes: number; last_row_id: number } +} + +interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement + first(column?: string): Promise + all(): Promise> + run(): Promise + raw(options?: { columnNames?: boolean }): Promise +} + +export interface MockD1Options { + /** Per-table fixtures, keyed by table name. Matched against INSERT/SELECT/UPDATE/DELETE FROM */ + fixtures?: Record + /** Fallback results returned when no fixture matches */ + results?: unknown[] +} + +const TABLE_NAME_RE = /(?:from|into|update)\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/i + +const extractTable = (sql: string): string | null => { + const match = TABLE_NAME_RE.exec(sql) + return match ? match[1] : null +} + +type SqlOp = 'select' | 'insert' | 'update' | 'delete' | 'other' + +const detectOp = (sql: string): SqlOp => { + const trimmed = sql.trimStart().toLowerCase() + if (trimmed.startsWith('select')) return 'select' + if (trimmed.startsWith('insert')) return 'insert' + if (trimmed.startsWith('update')) return 'update' + if (trimmed.startsWith('delete')) return 'delete' + return 'other' +} + +/** + * Creates a mock D1Database for testing + * + * @example + * ```ts + * // Legacy: fixed results for all queries + * const d1 = createMockD1([{ id: 1, name: 'Alice' }]) + * + * // Preferred: per-table fixtures + * const d1 = createMockD1({ fixtures: { users: [{ id: 1, name: 'Alice' }] } }) + * await d1.prepare('SELECT * FROM users').all() // returns users fixture + * ``` + */ +export function createMockD1(mockResultsOrOptions: unknown[] | MockD1Options = []): D1Database { + const options: MockD1Options = Array.isArray(mockResultsOrOptions) + ? { results: mockResultsOrOptions } + : mockResultsOrOptions + + // Per-instance mutable table storage, seeded with fixtures + const tables = new Map() + for (const [name, rows] of Object.entries(options.fixtures ?? {})) { + tables.set(name, [...rows]) + } + const fallback = options.results ?? [] + + const resolveRows = (sql: string): { rows: unknown[]; op: SqlOp; table: string | null } => { + const op = detectOp(sql) + const table = extractTable(sql) + if (table && tables.has(table)) { + return { rows: tables.get(table) ?? [], op, table } + } + return { rows: [...fallback], op, table } + } + + const createStatement = (sql: string): D1PreparedStatement => { + let boundValues: unknown[] = [] + const statement: D1PreparedStatement = { + bind(...values: unknown[]) { + boundValues = values + return statement + }, + + async first(column?: string): Promise { + const { rows } = resolveRows(sql) + const row = rows[0] as Record | undefined + if (!row) return null + if (column) return row[column] as T + return row as T + }, + + async all(): Promise> { + const { rows } = resolveRows(sql) + return { + results: rows as T[], + success: true, + meta: { duration: 0, changes: 0, last_row_id: 0 } + } + }, + + async run(): Promise { + const { op, table } = resolveRows(sql) + let changes = 0 + let lastRowId = 0 + if (op === 'insert' && table) { + const rows = tables.get(table) ?? [] + const bound = + boundValues.length > 0 + ? Object.fromEntries(boundValues.map((v, i) => [`col${i}`, v])) + : {} + rows.push(bound) + tables.set(table, rows) + changes = 1 + lastRowId = rows.length + } else if (op === 'delete' && table) { + const rows = tables.get(table) ?? [] + changes = rows.length + tables.set(table, []) + } else if (op === 'update' && table) { + changes = (tables.get(table) ?? []).length + } + return { + results: [], + success: true, + meta: { duration: 0, changes, last_row_id: lastRowId } + } + }, + + async raw(_options?: { columnNames?: boolean }): Promise { + const { rows } = resolveRows(sql) + return rows.map((row) => Object.values(row as Record)) as T[] + } + } + return statement + } + + return { + prepare(query: string): D1PreparedStatement { + return createStatement(query) + }, + + async exec(_query: string): Promise { + return { + results: [], + success: true, + meta: { duration: 0, changes: 0, last_row_id: 0 } + } + }, + + async batch(statements: D1PreparedStatement[]): Promise[]> { + return statements.map(() => ({ + results: [] as T[], + success: true, + meta: { duration: 0, changes: 0, last_row_id: 0 } + })) + }, + + async dump(): Promise { + return new ArrayBuffer(0) + }, + + withSession(_constraintOrBookmark?: string) { + return this + } + } as unknown as D1Database +} diff --git a/packages/devflare/src/test/utilities/env.ts b/packages/devflare/src/test/utilities/env.ts new file mode 100644 index 0000000..933d635 --- /dev/null +++ b/packages/devflare/src/test/utilities/env.ts @@ -0,0 +1,219 @@ +import type { Pipeline } from 'cloudflare:pipelines' +import { type MockArtifactsOptions, createMockArtifacts, isArtifactsBinding } from './artifacts' +import { createMockD1 } from './d1' +import { createMockKV } from './kv' +import { createMockImagesBinding, createMockMediaBinding } from './media' +import { + type MockDispatchNamespaceOptions, + type MockFetcherHandler, + type MockRateLimitOptions, + type MockWorkerLoaderOptions, + createMockDispatchNamespace, + createMockHyperdrive, + createMockMTLSCertificate, + createMockRateLimit, + createMockSecretsStoreSecret, + createMockVersionMetadata, + createMockWorkerLoader +} from './platform' +import { createMockQueue } from './queue' +import { createMockR2 } from './r2' +import { type MockWorkflowOptions, createMockPipeline, createMockWorkflow } from './workflows' + +export interface MockEnvOptions { + kv?: string[] + d1?: string[] + r2?: string[] + queues?: string[] + rateLimits?: Record + versionMetadata?: string + hyperdrive?: Record + workerLoaders?: string[] | Record + mtlsCertificates?: string[] | Record + dispatchNamespaces?: string[] | Record + workflows?: string[] | Record + pipelines?: string[] | Record + images?: string | ImagesBinding + media?: string | MediaBinding + artifacts?: string[] | Record + secretsStore?: Record + durableObjects?: string[] + vars?: Record + secrets?: Record + custom?: Record +} + +// ============================================================================= +// Mock Env Factory +// ============================================================================= + +/** + * Creates a complete mock environment with specified bindings + * + * @example + * ```ts + * const env = createMockEnv({ + * kv: ['CACHE'], + * d1: ['DB'], + * vars: { API_KEY: 'secret' } + * }) + * ``` + */ +export function createMockEnv(options: MockEnvOptions = {}): Record { + const env: Record = {} + + // Add KV bindings + if (options.kv) { + for (const name of options.kv) { + env[name] = createMockKV() + } + } + + // Add D1 bindings + if (options.d1) { + for (const name of options.d1) { + env[name] = createMockD1() + } + } + + // Add R2 bindings + if (options.r2) { + for (const name of options.r2) { + env[name] = createMockR2() + } + } + + // Add Queue bindings + if (options.queues) { + for (const name of options.queues) { + env[name] = createMockQueue() + } + } + + // Add Rate Limiting bindings + if (options.rateLimits) { + for (const [name, rateLimitOptions] of Object.entries(options.rateLimits)) { + env[name] = createMockRateLimit(rateLimitOptions) + } + } + + // Add Version Metadata binding + if (options.versionMetadata) { + env[options.versionMetadata] = createMockVersionMetadata() + } + + // Add Hyperdrive bindings + if (options.hyperdrive) { + for (const [name, binding] of Object.entries(options.hyperdrive)) { + env[name] = + typeof binding === 'string' ? createMockHyperdrive(binding) : binding + } + } + + // Add Worker Loader bindings + if (Array.isArray(options.workerLoaders)) { + for (const name of options.workerLoaders) { + env[name] = createMockWorkerLoader() + } + } else if (options.workerLoaders) { + for (const [name, workerLoaderOptions] of Object.entries(options.workerLoaders)) { + env[name] = createMockWorkerLoader(workerLoaderOptions) + } + } + + // Add mTLS Certificate bindings + if (Array.isArray(options.mtlsCertificates)) { + for (const name of options.mtlsCertificates) { + env[name] = createMockMTLSCertificate() + } + } else if (options.mtlsCertificates) { + for (const [name, handler] of Object.entries(options.mtlsCertificates)) { + env[name] = createMockMTLSCertificate(handler) + } + } + + // Add Dispatch Namespace bindings + if (Array.isArray(options.dispatchNamespaces)) { + for (const name of options.dispatchNamespaces) { + env[name] = createMockDispatchNamespace() + } + } else if (options.dispatchNamespaces) { + for (const [name, dispatchNamespaceOptions] of Object.entries(options.dispatchNamespaces)) { + env[name] = createMockDispatchNamespace(dispatchNamespaceOptions) + } + } + + // Add Workflow bindings + if (Array.isArray(options.workflows)) { + for (const name of options.workflows) { + env[name] = createMockWorkflow() + } + } else if (options.workflows) { + for (const [name, workflowOptions] of Object.entries(options.workflows)) { + env[name] = + 'create' in workflowOptions ? workflowOptions : createMockWorkflow(workflowOptions) + } + } + + // Add Pipeline bindings + if (Array.isArray(options.pipelines)) { + for (const name of options.pipelines) { + env[name] = createMockPipeline() + } + } else if (options.pipelines) { + for (const [name, pipeline] of Object.entries(options.pipelines)) { + env[name] = pipeline + } + } + + // Add Images binding + if (typeof options.images === 'string') { + env[options.images] = createMockImagesBinding() + } else if (options.images) { + env.IMAGES = options.images + } + + // Add Media Transformations binding + if (typeof options.media === 'string') { + env[options.media] = createMockMediaBinding() + } else if (options.media) { + env.MEDIA = options.media + } + + // Add Artifacts bindings + if (Array.isArray(options.artifacts)) { + for (const name of options.artifacts) { + env[name] = createMockArtifacts() + } + } else if (options.artifacts) { + for (const [name, artifactsOptions] of Object.entries(options.artifacts)) { + env[name] = isArtifactsBinding(artifactsOptions) + ? artifactsOptions + : createMockArtifacts(artifactsOptions) + } + } + + // Add Secrets Store bindings + if (options.secretsStore) { + for (const [name, value] of Object.entries(options.secretsStore)) { + env[name] = createMockSecretsStoreSecret(value) + } + } + + // Add vars + if (options.vars) { + Object.assign(env, options.vars) + } + + // Add secrets (same as vars for testing) + if (options.secrets) { + Object.assign(env, options.secrets) + } + + // Add custom bindings + if (options.custom) { + Object.assign(env, options.custom) + } + + return env +} diff --git a/packages/devflare/src/test/utilities/kv.ts b/packages/devflare/src/test/utilities/kv.ts new file mode 100644 index 0000000..f623e78 --- /dev/null +++ b/packages/devflare/src/test/utilities/kv.ts @@ -0,0 +1,163 @@ +// ============================================================================= +// Mock KV +// ============================================================================= + +interface KVGetOptions { + type?: 'text' | 'json' | 'arrayBuffer' | 'stream' + cacheTtl?: number +} + +interface KVListOptions { + prefix?: string + limit?: number + cursor?: string +} + +interface KVListResult { + keys: Array<{ name: string; expiration?: number; metadata?: unknown }> + list_complete: boolean + cursor?: string +} + +/** + * Creates a mock KVNamespace for testing + * + * @example + * ```ts + * const kv = createMockKV({ 'key': 'value' }) + * await kv.get('key') // 'value' + * ``` + */ +export function createMockKV(initialData: Record = {}): KVNamespace { + const store = new Map() + const metadata = new Map() + + const encoder = new TextEncoder() + const decoder = new TextDecoder() + + for (const [key, value] of Object.entries(initialData)) { + store.set(key, encoder.encode(value)) + } + + const toBytes = async ( + value: string | ArrayBuffer | ArrayBufferView | ReadableStream + ): Promise => { + if (typeof value === 'string') { + return encoder.encode(value) + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)) + } + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView + const copy = new Uint8Array(view.byteLength) + copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)) + return copy + } + const reader = (value as ReadableStream).getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const result = await reader.read() + if (result.done) break + if (result.value) { + const chunk = + result.value instanceof Uint8Array + ? result.value + : new Uint8Array(result.value as ArrayBufferLike) + chunks.push(chunk) + total += chunk.length + } + } + const combined = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined + } + + const decodeBytes = ( + bytes: Uint8Array, + type: 'text' | 'json' | 'arrayBuffer' | 'stream' + ): unknown => { + switch (type) { + case 'json': + return JSON.parse(decoder.decode(bytes)) + case 'arrayBuffer': { + const copy = new Uint8Array(bytes.length) + copy.set(bytes) + return copy.buffer + } + case 'stream': { + const copy = new Uint8Array(bytes.length) + copy.set(bytes) + return new ReadableStream({ + start(controller) { + controller.enqueue(copy) + controller.close() + } + }) + } + default: + return decoder.decode(bytes) + } + } + + const resolveType = ( + options?: KVGetOptions | string + ): 'text' | 'json' | 'arrayBuffer' | 'stream' => { + const type = typeof options === 'string' ? options : (options?.type ?? 'text') + return type as 'text' | 'json' | 'arrayBuffer' | 'stream' + } + + return { + async get(key: string, options?: KVGetOptions | string): Promise { + const bytes = store.get(key) + if (bytes === undefined) return null + return decodeBytes(bytes, resolveType(options)) + }, + + async put( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + _options?: unknown + ): Promise { + const bytes = await toBytes(value) + store.set(key, bytes) + }, + + async delete(key: string): Promise { + store.delete(key) + metadata.delete(key) + }, + + async list(options?: KVListOptions): Promise { + const prefix = options?.prefix ?? '' + const limit = options?.limit ?? 1000 + + const keys = Array.from(store.keys()) + .filter((key) => key.startsWith(prefix)) + .slice(0, limit) + .map((name) => ({ name })) + + return { + keys, + list_complete: keys.length < limit, + cursor: undefined + } + }, + + async getWithMetadata( + key: string, + options?: KVGetOptions | string + ): Promise<{ value: unknown; metadata: unknown }> { + const bytes = store.get(key) + return { + value: bytes === undefined ? null : decodeBytes(bytes, resolveType(options)), + metadata: metadata.get(key) ?? null + } + } + } as KVNamespace +} diff --git a/packages/devflare/src/test/utilities/media.ts b/packages/devflare/src/test/utilities/media.ts new file mode 100644 index 0000000..13b4b44 --- /dev/null +++ b/packages/devflare/src/test/utilities/media.ts @@ -0,0 +1,166 @@ +// ============================================================================= +// Mock Images Binding +// ============================================================================= + +export interface MockImagesBindingOptions { + info?: ImageInfoResponse + response?: Response +} + +function createEmptyImageStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + } + }) +} + +function createMockImageTransformationResult(response: Response): ImageTransformationResult { + return { + response(): Response { + return response.clone() + }, + contentType(): string { + return response.headers.get('Content-Type') ?? 'image/png' + }, + image(): ReadableStream { + const cloned = response.clone() + return cloned.body ?? createEmptyImageStream() + } + } as ImageTransformationResult +} + +function createMockImageTransformer(response: Response): ImageTransformer { + const transformer: ImageTransformer = { + transform(_transform: ImageTransform): ImageTransformer { + return transformer + }, + draw( + _image: ReadableStream | ImageTransformer, + _options?: ImageDrawOptions + ): ImageTransformer { + return transformer + }, + async output(_options: ImageOutputOptions): Promise { + return createMockImageTransformationResult(response) + } + } + + return transformer +} + +function createMockHostedImagesBinding(): HostedImagesBinding { + const unsupported = () => { + throw new Error( + 'Mock Images hosted API is not implemented. Pass a custom ImagesBinding through createMockEnv({ images }) if your test needs hosted image behavior.' + ) + } + + return { + image(_imageId: string): ImageHandle { + return { + details: unsupported, + bytes: unsupported, + update: unsupported, + delete: unsupported + } as ImageHandle + }, + upload: unsupported, + list: unsupported + } as HostedImagesBinding +} + +/** + * Creates an Images binding for pure unit tests. + */ +export function createMockImagesBinding(options: MockImagesBindingOptions = {}): ImagesBinding { + const response = + options.response ?? + new Response('', { + headers: { 'Content-Type': 'image/png' } + }) + const info = options.info ?? { + format: response.headers.get('Content-Type') ?? 'image/png', + fileSize: 0, + width: 0, + height: 0 + } + + return { + async info( + _stream: ReadableStream, + _options?: ImageInputOptions + ): Promise { + return info + }, + input(_stream: ReadableStream, _options?: ImageInputOptions): ImageTransformer { + return createMockImageTransformer(response) + }, + hosted: createMockHostedImagesBinding() + } as ImagesBinding +} + +// ============================================================================= +// Mock Media Transformations Binding +// ============================================================================= + +export interface MockMediaBindingOptions { + response?: Response +} + +function createEmptyMediaStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + } + }) +} + +function createMockMediaTransformationResult(response: Response): MediaTransformationResult { + return { + async media(): Promise> { + const cloned = response.clone() + return cloned.body ?? createEmptyMediaStream() + }, + async response(): Promise { + return response.clone() + }, + async contentType(): Promise { + return response.headers.get('Content-Type') ?? 'video/mp4' + } + } as MediaTransformationResult +} + +function createMockMediaTransformer(response: Response): MediaTransformer { + const transformer: MediaTransformer = { + transform(_transform?: MediaTransformationInputOptions): MediaTransformationGenerator { + return { + output(_output?: MediaTransformationOutputOptions): MediaTransformationResult { + return createMockMediaTransformationResult(response) + } + } + }, + output(_output?: MediaTransformationOutputOptions): MediaTransformationResult { + return createMockMediaTransformationResult(response) + } + } + + return transformer +} + +/** + * Creates a Media Transformations binding for pure unit tests. + */ +export function createMockMediaBinding(options: MockMediaBindingOptions = {}): MediaBinding { + const response = + options.response ?? + new Response('', { + headers: { 'Content-Type': 'video/mp4' } + }) + + return { + input(_media: ReadableStream): MediaTransformer { + return createMockMediaTransformer(response) + } + } as MediaBinding +} diff --git a/packages/devflare/src/test/utilities/platform.ts b/packages/devflare/src/test/utilities/platform.ts new file mode 100644 index 0000000..bdcd19e --- /dev/null +++ b/packages/devflare/src/test/utilities/platform.ts @@ -0,0 +1,191 @@ +// ============================================================================= +// Mock Rate Limit +// ============================================================================= + +export interface MockRateLimitOptions { + limit?: number + period?: 10 | 60 +} + +/** + * Creates a local fixed-window RateLimit binding for testing. + */ +export function createMockRateLimit(options: MockRateLimitOptions = {}): RateLimit { + const limit = options.limit ?? Number.MAX_SAFE_INTEGER + const periodMs = (options.period ?? 60) * 1000 + const windows = new Map() + + return { + async limit({ key }: RateLimitOptions): Promise { + const now = Date.now() + const existing = windows.get(key) + if (!existing || existing.resetAt <= now) { + windows.set(key, { count: 1, resetAt: now + periodMs }) + return { success: limit >= 1 } + } + + existing.count += 1 + return { success: existing.count <= limit } + } + } as RateLimit +} + +// ============================================================================= +// Mock Version Metadata +// ============================================================================= + +export function createMockVersionMetadata( + metadata: Partial = {} +): WorkerVersionMetadata { + return { + id: metadata.id ?? 'devflare-local-version', + tag: metadata.tag ?? 'local', + timestamp: metadata.timestamp ?? '1970-01-01T00:00:00.000Z' + } +} + +// ============================================================================= +// Mock Secrets Store Secret +// ============================================================================= + +/** + * Creates a Secrets Store binding whose get() method returns a fixed value. + */ +export function createMockSecretsStoreSecret(value: string): SecretsStoreSecret { + return { + async get(): Promise { + return value + } + } as SecretsStoreSecret +} + +// ============================================================================= +// Mock Hyperdrive +// ============================================================================= + +function defaultPortForDatabaseUrl(url: URL): number { + if (url.port) { + return Number(url.port) + } + + return url.protocol === 'mysql:' ? 3306 : 5432 +} + +/** + * Creates a Hyperdrive binding around a local database connection string. + */ +export function createMockHyperdrive(connectionString: string): Hyperdrive { + const url = new URL(connectionString) + + return { + connectionString, + host: url.hostname, + port: defaultPortForDatabaseUrl(url), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: decodeURIComponent(url.pathname.replace(/^\//, '')), + connect(): Socket { + throw new Error( + 'Mock Hyperdrive connect() is not implemented. Use connectionString with your database client, or run a Miniflare-backed test for socket behavior.' + ) + } + } as Hyperdrive +} + +// ============================================================================= +// Mock Worker Loader +// ============================================================================= + +export interface MockWorkerLoaderOptions { + stub?: WorkerStub +} + +function createDefaultWorkerStub(): WorkerStub { + return { + getEntrypoint() { + throw new Error( + 'Mock WorkerLoader stub has no entrypoint. Pass createMockWorkerLoader({ stub }) for behavior.' + ) + }, + getDurableObjectClass() { + throw new Error( + 'Mock WorkerLoader stub has no Durable Object class. Pass createMockWorkerLoader({ stub }) for behavior.' + ) + } + } as unknown as WorkerStub +} + +/** + * Creates a Worker Loader binding for pure unit tests. + */ +export function createMockWorkerLoader(options: MockWorkerLoaderOptions = {}): WorkerLoader { + const stub = options.stub ?? createDefaultWorkerStub() + + return { + get( + _name: string | null, + _getCode: () => WorkerLoaderWorkerCode | Promise + ): WorkerStub { + return stub + }, + load(_code: WorkerLoaderWorkerCode): WorkerStub { + return stub + } + } as WorkerLoader +} + +// ============================================================================= +// Mock mTLS Certificate +// ============================================================================= + +export type MockFetchInput = string | Request | URL + +export type MockFetcherHandler = ( + input: MockFetchInput, + init?: RequestInit +) => Response | Promise + +function defaultMTLSCertificateHandler(): never { + throw new Error( + 'Mock mTLS Certificate Fetcher has no handler. Pass createMockMTLSCertificate(handler) for behavior.' + ) +} + +/** + * Creates an mTLS certificate binding fetcher for pure unit tests. + */ +export function createMockMTLSCertificate( + handler: MockFetcherHandler = defaultMTLSCertificateHandler +): Fetcher { + return { + async fetch(input: MockFetchInput, init?: RequestInit): Promise { + return handler(input, init) + } + } as unknown as Fetcher +} + +// ============================================================================= +// Mock Dispatch Namespace +// ============================================================================= + +export interface MockDispatchNamespaceOptions { + workers?: Record +} + +/** + * Creates a Dispatch Namespace binding for pure unit tests. + */ +export function createMockDispatchNamespace( + options: MockDispatchNamespaceOptions = {} +): DispatchNamespace { + return { + get(name: string): Fetcher { + const worker = options.workers?.[name] + if (!worker) { + throw new Error(`Mock DispatchNamespace has no worker named "${name}".`) + } + + return typeof worker === 'function' ? createMockMTLSCertificate(worker) : worker + } + } as DispatchNamespace +} diff --git a/packages/devflare/src/test/utilities/queue.ts b/packages/devflare/src/test/utilities/queue.ts new file mode 100644 index 0000000..9534718 --- /dev/null +++ b/packages/devflare/src/test/utilities/queue.ts @@ -0,0 +1,47 @@ +// ============================================================================= +// Mock Queue +// ============================================================================= + +/** + * Creates a mock Queue for testing + */ +export function createMockQueue(): Queue { + const messages: Array<{ body: unknown; options?: unknown }> = [] + const metrics: QueueMetrics = { + backlogCount: 0, + backlogBytes: 0 + } + const response = { metadata: { metrics } } + + return { + async metrics(): Promise { + return metrics + }, + + async send(message: unknown, options?: QueueSendOptions): Promise { + messages.push({ body: message, options }) + return response + }, + + async sendBatch( + batch: Iterable, + options?: QueueSendBatchOptions + ): Promise { + for (const message of batch) { + messages.push({ + body: message.body, + options: { + contentType: message.contentType, + delaySeconds: message.delaySeconds ?? options?.delaySeconds + } + }) + } + return response + }, + + // Test helper to inspect sent messages + _getMessages() { + return messages + } + } as Queue & { _getMessages(): Array<{ body: unknown; options?: unknown }> } +} diff --git a/packages/devflare/src/test/utilities/r2.ts b/packages/devflare/src/test/utilities/r2.ts new file mode 100644 index 0000000..aa72d12 --- /dev/null +++ b/packages/devflare/src/test/utilities/r2.ts @@ -0,0 +1,145 @@ +// ============================================================================= +// Mock R2 +// ============================================================================= + +// Using native Cloudflare R2 types from @cloudflare/workers-types + +/** + * Creates a mock R2Bucket for testing + * + * @example + * ```ts + * const r2 = createMockR2() + * await r2.put('file.txt', 'content') + * const obj = await r2.get('file.txt') + * ``` + */ +export function createMockR2(): R2Bucket { + const store = new Map }>() + + const createR2Object = (key: string, content: string, metadata?: Record) => { + const encoder = new TextEncoder() + const data = encoder.encode(content) + + return { + key, + version: '1', + size: data.length, + etag: `"${key}-etag"`, + httpEtag: `"${key}-etag"`, + uploaded: new Date(), + httpMetadata: metadata ?? {}, + customMetadata: {}, + checksums: {}, + storageClass: 'Standard', + body: new ReadableStream({ + start(controller) { + controller.enqueue(data) + controller.close() + } + }), + bodyUsed: false, + async arrayBuffer() { + return new Uint8Array(data).buffer as ArrayBuffer + }, + async text() { + return content + }, + async json() { + return JSON.parse(content) as T + }, + async blob() { + return new Blob([data]) + }, + writeHttpMetadata(headers: Headers) { + // No-op + } + } + } + + return { + async put( + key: string, + value: string | ArrayBuffer | ReadableStream | Blob | null, + options?: unknown + ) { + let content: string + if (typeof value === 'string') { + content = value + } else if (value instanceof ArrayBuffer) { + content = new TextDecoder().decode(value) + } else if (value instanceof Blob) { + content = await value.text() + } else if (value === null) { + content = '' + } else { + // ReadableStream + const reader = value.getReader() + const chunks: string[] = [] + let done = false + while (!done) { + const result = await reader.read() + done = result.done + if (result.value) { + chunks.push(new TextDecoder().decode(result.value)) + } + } + content = chunks.join('') + } + + store.set(key, { content }) + return createR2Object(key, content) + }, + + async get(key: string, options?: unknown) { + const item = store.get(key) + if (!item) return null + return createR2Object(key, item.content, item.metadata) + }, + + async head(key: string) { + const item = store.get(key) + if (!item) return null + return { + key, + version: '1', + size: new TextEncoder().encode(item.content).length, + etag: `"${key}-etag"`, + httpEtag: `"${key}-etag"`, + uploaded: new Date(), + httpMetadata: item.metadata ?? {}, + customMetadata: {}, + checksums: {}, + storageClass: 'Standard', + writeHttpMetadata(headers: Headers) {} + } + }, + + async delete(keys: string | string[]) { + const keyArray = Array.isArray(keys) ? keys : [keys] + for (const key of keyArray) { + store.delete(key) + } + }, + + async list(options?: unknown) { + const objects = Array.from(store.entries()).map(([key, { content, metadata }]) => + createR2Object(key, content, metadata) + ) + + return { + objects, + truncated: false, + delimitedPrefixes: [] + } + }, + + async createMultipartUpload(key: string, options?: unknown) { + throw new Error('Multipart upload not implemented in mock') + }, + + async resumeMultipartUpload(key: string, uploadId: string) { + throw new Error('Multipart upload not implemented in mock') + } + } as unknown as R2Bucket +} diff --git a/packages/devflare/src/test/utilities/workflows.ts b/packages/devflare/src/test/utilities/workflows.ts new file mode 100644 index 0000000..5e6e953 --- /dev/null +++ b/packages/devflare/src/test/utilities/workflows.ts @@ -0,0 +1,131 @@ +import type { Pipeline, PipelineRecord } from 'cloudflare:pipelines' + +// ============================================================================= +// Mock Workflow +// ============================================================================= + +type MockWorkflowStatus = + | 'queued' + | 'running' + | 'paused' + | 'errored' + | 'terminated' + | 'complete' + | 'waiting' + | 'waitingForPause' + | 'unknown' + +export interface MockWorkflowInstanceOptions { + status?: MockWorkflowStatus + output?: unknown + error?: { name: string; message: string } +} + +export interface MockWorkflowOptions { + instances?: Record +} + +function createMockWorkflowInstance( + id: string, + options: MockWorkflowInstanceOptions = {} +): WorkflowInstance { + let status: MockWorkflowStatus = options.status ?? 'queued' + let output = options.output + let error = options.error + + return { + id, + async pause(): Promise { + status = 'paused' + }, + async resume(): Promise { + status = 'running' + }, + async terminate(): Promise { + status = 'terminated' + }, + async restart(): Promise { + status = 'queued' + error = undefined + output = undefined + }, + async status() { + return { + status, + ...(error && { error }), + ...(output !== undefined && { output }) + } + }, + async sendEvent(_event: { type: string; payload: unknown }): Promise { + // No-op; pure unit tests can assert their own side effects around the mock. + } + } as WorkflowInstance +} + +/** + * Creates a Workflow binding for pure unit tests. + */ +export function createMockWorkflow( + options: MockWorkflowOptions = {} +): Workflow { + const instances = new Map() + let sequence = 0 + + for (const [id, instanceOptions] of Object.entries(options.instances ?? {})) { + instances.set(id, createMockWorkflowInstance(id, instanceOptions)) + } + + const createInstance = (id: string): WorkflowInstance => { + if (instances.has(id)) { + throw new Error(`Mock Workflow already has an instance named "${id}".`) + } + + const instance = createMockWorkflowInstance(id) + instances.set(id, instance) + return instance + } + + return { + async get(id: string): Promise { + const instance = instances.get(id) + if (!instance) { + throw new Error(`Mock Workflow has no instance named "${id}".`) + } + return instance + }, + async create(options?: WorkflowInstanceCreateOptions): Promise { + const id = options?.id ?? `mock-workflow-${++sequence}` + return createInstance(id) + }, + async createBatch(batch: WorkflowInstanceCreateOptions[]): Promise { + return batch.map((options) => { + const id = options.id ?? `mock-workflow-${++sequence}` + return createInstance(id) + }) + } + } as Workflow +} + +// ============================================================================= +// Mock Pipeline +// ============================================================================= + +export type MockPipeline = Pipeline & { + _getRecords(): T[] +} + +/** + * Creates a Pipeline binding for pure unit tests. + */ +export function createMockPipeline(): MockPipeline { + const records: T[] = [] + + return { + async send(batch: T[]): Promise { + records.push(...batch) + }, + _getRecords(): T[] { + return [...records] + } + } +} diff --git a/packages/devflare/src/test/worker.ts b/packages/devflare/src/test/worker.ts new file mode 100644 index 0000000..df6dd96 --- /dev/null +++ b/packages/devflare/src/test/worker.ts @@ -0,0 +1,263 @@ +// ============================================================================= +// Worker Test Helper โ€” Trigger fetch handlers in Bun tests +// ============================================================================= +// Usage: +// import { cf } from 'devflare/test' +// +// // Trigger the fetch handler with a request +// const response = await cf.worker.fetch(new Request('http://localhost/api')) +// +// // Shorthand for GET requests +// const response = await cf.worker.get('/api/users') +// +// // Shorthand for POST requests +// const response = await cf.worker.post('/api/users', { name: 'Alice' }) +// ============================================================================= + +import { join } from 'path' +import { createFetchEvent, invokeFetchModule, resolveFetchHandler, runWithEventContext } from '../runtime' +import { createRouteResolve, matchFetchRoute } from '../runtime' +import type { RouteSegment } from '../runtime/router/types' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface WorkerFetchOptions { + /** Request method (default: GET) */ + method?: string + /** Request headers */ + headers?: Record + /** Request body (will be JSON-serialized if object) */ + body?: unknown +} + +// ----------------------------------------------------------------------------- +// Global State (set by createTestContext) +// ----------------------------------------------------------------------------- + +let fetchHandlerPath: string | null = null +let configDir: string | null = null +let testEnvGetter: (() => Record) | null = null +let fileRoutes: Array<{ + filePath: string + routePath: string + segments: readonly RouteSegment[] +}> = [] + +// ----------------------------------------------------------------------------- +// Configuration (called by createTestContext) +// ----------------------------------------------------------------------------- + +/** + * Configure the worker test helper + * @internal Called by createTestContext to set up handler path and env + */ +export function configureWorker(options: { + handlerPath: string | null + routes?: Array<{ + filePath: string + routePath: string + segments: readonly RouteSegment[] + }> + configDir: string + getEnv: () => Record +}): void { + fetchHandlerPath = options.handlerPath + fileRoutes = options.routes ?? [] + configDir = options.configDir + testEnvGetter = options.getEnv +} + +/** + * Reset worker helper state + * @internal Called when test context is disposed + */ +export function resetWorkerState(): void { + fetchHandlerPath = null + fileRoutes = [] + configDir = null + testEnvGetter = null +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + +/** + * Trigger the fetch handler with a request. + * This directly invokes the fetch handler function from your config. + * + * @param request - Request object or URL string + * @param options - Optional fetch options (method, headers, body) + * @returns Response from the handler + * + * @example + * ```ts + * // With Request object + * const response = await cf.worker.fetch(new Request('http://localhost/api')) + * + * // With URL string + * const response = await cf.worker.fetch('http://localhost/api') + * + * // With options + * const response = await cf.worker.fetch('/api/users', { + * method: 'POST', + * body: { name: 'Alice' } + * }) + * ``` + */ +async function fetch( + request: Request | string, + options?: WorkerFetchOptions +): Promise { + if (!fetchHandlerPath && fileRoutes.length === 0) { + throw new Error( + 'Fetch handler not configured. Make sure your devflare.config.ts has files.fetch set or a routes directory is available, ' + + 'and that the corresponding files exist (defaults: src/fetch.ts and src/routes/**).' + ) + } + + if (!configDir || !testEnvGetter) { + throw new Error( + 'Worker helper not initialized. Call createTestContext() before using cf.worker.fetch()' + ) + } + + const workerConfigDir = configDir + const getEnv = testEnvGetter + + // Normalize request + let req: Request + if (typeof request === 'string') { + const url = request.startsWith('http') ? request : `http://localhost${request.startsWith('/') ? '' : '/'}${request}` + const headers = new Headers(options?.headers) + + let body: BodyInit | undefined + if (options?.body !== undefined) { + if (typeof options.body === 'string') { + body = options.body + } else { + body = JSON.stringify(options.body) + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + } + } + + req = new Request(url, { + method: options?.method ?? 'GET', + headers, + body + }) + } else { + req = request + } + + // Import the fetch handler + const handlerModule = fetchHandlerPath + ? await import(join(workerConfigDir, fetchHandlerPath)) + : {} + const routeModules = await Promise.all(fileRoutes.map(async (route) => { + return { + ...route, + module: await import(join(workerConfigDir, route.filePath)) + } + })) + + const methodExports = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'ALL'] + const hasMethodHandler = methodExports.some((method) => { + return typeof handlerModule[method] === 'function' + || typeof handlerModule.default?.[method] === 'function' + }) + + if (!resolveFetchHandler(handlerModule) && !hasMethodHandler && routeModules.length === 0) { + throw new Error( + `Fetch handler at "${fetchHandlerPath}" must export one of:\n` + + `- request-wide \"handle\" middleware\n` + + `- named \"fetch\"\n` + + `- default fetch handler\n` + + `- HTTP method exports such as \"GET\" or \"POST\"` + ) + } + + // Create execution context + const waitUntilPromises: Promise[] = [] + const ctx: ExecutionContext = { + waitUntil(promise: Promise) { + waitUntilPromises.push(promise) + }, + passThroughOnException() { }, + props: {} + } + + // Get the test env + const env = getEnv() + const initialRouteMatch = routeModules.length > 0 ? matchFetchRoute(routeModules, req) : null + const fetchEvent = createFetchEvent(req, env, ctx, { + params: initialRouteMatch?.params ?? {} + }) + + // Call the handler + const response = await runWithEventContext( + fetchEvent, + () => invokeFetchModule( + handlerModule, + fetchEvent, + routeModules.length > 0 ? createRouteResolve(routeModules, fetchEvent) : undefined + ) + ) + + // Note: We don't wait for waitUntil promises here because the response + // should be returned immediately. waitUntil is for background work. + + return response +} + +/** + * Shorthand for GET requests + */ +async function get(path: string, headers?: Record): Promise { + return fetch(path, { method: 'GET', headers }) +} + +/** + * Shorthand for POST requests with JSON body + */ +async function post(path: string, body?: unknown, headers?: Record): Promise { + return fetch(path, { method: 'POST', body, headers }) +} + +/** + * Shorthand for PUT requests with JSON body + */ +async function put(path: string, body?: unknown, headers?: Record): Promise { + return fetch(path, { method: 'PUT', body, headers }) +} + +/** + * Shorthand for DELETE requests + */ +async function del(path: string, headers?: Record): Promise { + return fetch(path, { method: 'DELETE', headers }) +} + +/** + * Shorthand for PATCH requests with JSON body + */ +async function patch(path: string, body?: unknown, headers?: Record): Promise { + return fetch(path, { method: 'PATCH', body, headers }) +} + +// ----------------------------------------------------------------------------- +// Export +// ----------------------------------------------------------------------------- + +export const worker = { + fetch, + get, + post, + put, + delete: del, + patch +} diff --git a/packages/devflare/src/transform/durable-object.ts b/packages/devflare/src/transform/durable-object.ts new file mode 100644 index 0000000..786b59e --- /dev/null +++ b/packages/devflare/src/transform/durable-object.ts @@ -0,0 +1,459 @@ +// ============================================================================= +// Durable Object Transform โ€” Wraps DO classes with context injection +// ============================================================================= +// Transforms Durable Object classes to automatically inject request context +// so that env, ctx, event, and locals proxies work inside DO methods +// ============================================================================= + +import ts from 'typescript' +import MagicString from 'magic-string' + +// ============================================================================= +// Class Detection (TypeScript AST-based) +// ============================================================================= + +/** + * Information about a detected Durable Object class + */ +export interface DOClassInfo { + /** Class name */ + name: string + /** Whether the class extends DurableObject */ + extendsBase: boolean + /** Whether the class has @durableObject decorator */ + hasDecorator: boolean + /** Parsed decorator options (if any) */ + decoratorOptions?: Record +} + +/** + * Returns the trailing identifier name of an expression used as a class base. + * Handles `DurableObject`, `Cloudflare.DurableObject`, `DurableObject`. + */ +function getBaseIdentifierName(expr: ts.Expression): string | undefined { + // Strip generic type arguments โ€” they live on ExpressionWithTypeArguments, + // so here we only see the call/identifier/property-access expression. + if (ts.isIdentifier(expr)) { + return expr.text + } + if (ts.isPropertyAccessExpression(expr)) { + return expr.name.text + } + return undefined +} + +/** + * Returns true when the given heritage clause expression refers to DurableObject. + */ +function extendsDurableObject(node: ts.ClassDeclaration): boolean { + const clauses = node.heritageClauses + if (!clauses) return false + for (const clause of clauses) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue + for (const type of clause.types) { + const name = getBaseIdentifierName(type.expression) + if (name === 'DurableObject') return true + } + } + return false +} + +/** + * Returns the `@durableObject` decorator (if any) on a class declaration, + * preferring `ts.getDecorators` which understands both legacy and modifier-style decorators. + */ +function getDurableObjectDecorator(node: ts.ClassDeclaration): ts.Decorator | undefined { + const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined + if (!decorators) return undefined + for (const decorator of decorators) { + const expr = decorator.expression + if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === 'durableObject') { + return decorator + } + if (ts.isIdentifier(expr) && expr.text === 'durableObject') { + return decorator + } + } + return undefined +} + +/** + * Parse the first argument of the `@durableObject(...)` decorator into a plain options object. + * Only supports the shapes the runtime uses: boolean literals and arrays of string literals. + */ +function parseDecoratorOptions(decorator: ts.Decorator): Record | undefined { + const expr = decorator.expression + if (!ts.isCallExpression(expr)) return undefined + const arg = expr.arguments[0] + if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined + + const result: Record = {} + for (const prop of arg.properties) { + if (!ts.isPropertyAssignment(prop)) continue + let key: string | undefined + if (ts.isIdentifier(prop.name)) key = prop.name.text + else if (ts.isStringLiteral(prop.name)) key = prop.name.text + if (!key) continue + + const value = prop.initializer + if (value.kind === ts.SyntaxKind.TrueKeyword) { + result[key] = true + } else if (value.kind === ts.SyntaxKind.FalseKeyword) { + result[key] = false + } else if (ts.isStringLiteralLike(value)) { + result[key] = value.text + } else if (ts.isNumericLiteral(value)) { + result[key] = Number(value.text) + } else if (ts.isArrayLiteralExpression(value)) { + const items: string[] = [] + for (const el of value.elements) { + if (ts.isStringLiteralLike(el)) items.push(el.text) + } + result[key] = items + } + } + return result +} + +/** + * Walk top-level statements and collect DO class information. + */ +function collectDurableObjectClasses(code: string): DOClassInfo[] { + const sourceFile = ts.createSourceFile( + 'durable-object.tsx', + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ) + + const classMap = new Map() + + const inspect = (node: ts.ClassDeclaration) => { + if (!node.name) return + // Only consider exported classes to preserve existing behavior + // of the regex-based detector and the downstream transform. + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined + const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false + if (!isExported) return + + const name = node.name.text + const extendsBase = extendsDurableObject(node) + const decorator = getDurableObjectDecorator(node) + const hasDecorator = decorator !== undefined + + if (!extendsBase && !hasDecorator) return + + const existing = classMap.get(name) + const info: DOClassInfo = existing ?? { name, extendsBase: false, hasDecorator: false } + if (extendsBase) info.extendsBase = true + if (hasDecorator) { + info.hasDecorator = true + const options = parseDecoratorOptions(decorator) + if (options) info.decoratorOptions = options + } + classMap.set(name, info) + } + + for (const statement of sourceFile.statements) { + if (ts.isClassDeclaration(statement)) { + inspect(statement) + } + } + + return Array.from(classMap.values()) +} + +/** + * Finds all class names that extend DurableObject or have @durableObject decorator + */ +export function findDurableObjectClasses(code: string): string[] { + return collectDurableObjectClasses(code).map((info) => info.name) +} + +/** + * Finds detailed info about all Durable Object classes + */ +export function findDurableObjectClassesDetailed(code: string): DOClassInfo[] { + return collectDurableObjectClasses(code) +} + +// ============================================================================= +// Wrapper Generation +// ============================================================================= + +/** + * Options for wrapper generation + */ +export interface WrapperOptions { + alarms?: boolean + websockets?: boolean +} + +/** + * Generates a wrapper class that injects context into DO methods + */ +export function generateWrapper(className: string, options: WrapperOptions = {}): string { + const includeAlarms = options.alarms ?? false + const includeWebsockets = options.websockets ?? false + + let wrapper = ` +// ============ Devflare DO Wrapper for ${className} ============ +import { createDurableObjectAlarmEvent, createDurableObjectFetchEvent, createDurableObjectWebSocketCloseEvent, createDurableObjectWebSocketErrorEvent, createDurableObjectWebSocketMessageEvent, runWithEventContext } from 'devflare/runtime' + +const __Original${className} = ${className} + +class ${className}Wrapper extends __Original${className} { + async fetch(request: Request): Promise { + const __devflareEvent = createDurableObjectFetchEvent(request, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.fetch(__devflareEvent) + ) + } +` + + if (includeAlarms) { + wrapper += ` + async alarm(): Promise { + const __devflareEvent = createDurableObjectAlarmEvent(this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.alarm?.(__devflareEvent) ?? Promise.resolve() + ) + } +` + } + + if (includeWebsockets) { + wrapper += ` + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const __devflareEvent = createDurableObjectWebSocketMessageEvent(ws, message, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketMessage?.(__devflareEvent, message) ?? Promise.resolve() + ) + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const __devflareEvent = createDurableObjectWebSocketCloseEvent(ws, code, reason, wasClean, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketClose?.(__devflareEvent, code, reason, wasClean) ?? Promise.resolve() + ) + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + const __devflareEvent = createDurableObjectWebSocketErrorEvent(ws, error, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketError?.(__devflareEvent, error) ?? Promise.resolve() + ) + } +` + } + + wrapper += `} + +export { ${className}Wrapper as ${className} } +// ============ End Devflare DO Wrapper ============ +` + + return wrapper +} + +// ============================================================================= +// Full Transform +// ============================================================================= + +export interface TransformResult { + code: string + map: ReturnType +} + +/** + * Transforms a source file to wrap Durable Object classes + * + * @param code - Source code to transform + * @param id - File path/id for source mapping + * @returns Transformed code with source map, or null if no transforms needed + */ +export async function transformDurableObject( + code: string, + id: string +): Promise { + const doClasses = findDurableObjectClassesDetailed(code) + + if (doClasses.length === 0) { + return null + } + + const s = new MagicString(code) + + // Process each DO class + for (const classInfo of doClasses) { + const className = classInfo.name + + if (classInfo.extendsBase) { + // Class extends DurableObject โ€” rename to __Original + const exportPattern = new RegExp( + `export\\s+class\\s+${className}\\s+extends\\s+DurableObject`, + 'g' + ) + + let match: RegExpExecArray | null + while ((match = exportPattern.exec(code)) !== null) { + // Change "export class X" to "class __OriginalX" + const start = match.index + const exportKeywordEnd = start + 'export '.length + + // Remove export keyword + s.overwrite(start, exportKeywordEnd, '') + + // Rename class to __Original prefix + const classNameStart = start + match[0].indexOf(className) + const classNameEnd = classNameStart + className.length + s.overwrite(classNameStart, classNameEnd, `__Original${className}`) + } + } else if (classInfo.hasDecorator) { + // Class has @durableObject decorator but doesn't extend DurableObject + // Need to: + // 1. Remove the decorator + // 2. Make it extend DurableObject + // 3. Rename to __Original + + const decoratorPattern = new RegExp( + `@durableObject\\s*\\([^)]*\\)\\s*\\n?\\s*export\\s+class\\s+${className}(?:\\s+extends\\s+(\\w+))?`, + 'g' + ) + + let match: RegExpExecArray | null + while ((match = decoratorPattern.exec(code)) !== null) { + const start = match.index + const existingBaseClass = match[1] + + // Calculate positions + const decoratorEnd = code.indexOf(')', start) + 1 + const exportStart = code.indexOf('export', decoratorEnd) + const classDefEnd = start + match[0].length + + // Remove decorator + s.overwrite(start, exportStart, '') + + // The class becomes: class __OriginalClassName extends DurableObject + const classNameStart = code.indexOf(className, exportStart) + const classNameEnd = classNameStart + className.length + + if (existingBaseClass) { + // Already extends something, rename to __Original + s.overwrite(classNameStart, classNameEnd, `__Original${className}`) + } else { + // Doesn't extend anything, add extends DurableObject + s.overwrite( + classNameStart, + classDefEnd, + `__Original${className} extends DurableObject` + ) + } + } + } + } + + // Add imports and wrappers at the end + const imports = `\nimport { createDurableObjectAlarmEvent, createDurableObjectFetchEvent, createDurableObjectWebSocketCloseEvent, createDurableObjectWebSocketErrorEvent, createDurableObjectWebSocketMessageEvent, runWithEventContext } from 'devflare/runtime'\n` + s.prepend(imports) + + // Add wrapper classes at the end + for (const classInfo of doClasses) { + const className = classInfo.name + const options = classInfo.decoratorOptions || {} + + // Determine which handlers to include based on options + const includeAlarms = options.alarms === true + const includeWebsockets = options.websockets === true + + const wrapper = generateWrapperCodeInternal(className, { + alarms: includeAlarms, + websockets: includeWebsockets + }) + s.append(wrapper) + } + + return { + code: s.toString(), + map: s.generateMap({ + source: id, + file: id + '.map', + includeContent: true + }) + } +} + +/** + * Generate wrapper code for a DO class (internal - omits import since transform adds it) + */ +function generateWrapperCodeInternal( + className: string, + options: { alarms: boolean; websockets: boolean } +): string { + let wrapper = ` + +// ============ Devflare DO Wrapper for ${className} ============ +class ${className}Wrapper extends __Original${className} { + async fetch(request: Request): Promise { + const __devflareEvent = createDurableObjectFetchEvent(request, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.fetch(__devflareEvent) + ) + } +` + + if (options.alarms) { + wrapper += ` + async alarm(): Promise { + const __devflareEvent = createDurableObjectAlarmEvent(this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.alarm?.(__devflareEvent) ?? Promise.resolve() + ) + } +` + } + + if (options.websockets) { + wrapper += ` + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const __devflareEvent = createDurableObjectWebSocketMessageEvent(ws, message, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketMessage?.(__devflareEvent, message) ?? Promise.resolve() + ) + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + const __devflareEvent = createDurableObjectWebSocketCloseEvent(ws, code, reason, wasClean, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketClose?.(__devflareEvent, code, reason, wasClean) ?? Promise.resolve() + ) + } + + async webSocketError(ws: WebSocket, error: unknown): Promise { + const __devflareEvent = createDurableObjectWebSocketErrorEvent(ws, error, this.env, this.ctx) + return runWithEventContext( + __devflareEvent, + () => super.webSocketError?.(__devflareEvent, error) ?? Promise.resolve() + ) + } +` + } + + wrapper += `} + +export { ${className}Wrapper as ${className} } +// ============ End Devflare DO Wrapper ============ +` + + return wrapper +} diff --git a/packages/devflare/src/transform/index.ts b/packages/devflare/src/transform/index.ts new file mode 100644 index 0000000..b26bd11 --- /dev/null +++ b/packages/devflare/src/transform/index.ts @@ -0,0 +1,20 @@ +// ============================================================================= +// Transform Module โ€” Public Exports +// ============================================================================= + +export { + transformDurableObject, + findDurableObjectClasses, + generateWrapper, + type TransformResult +} from './durable-object' + +export { + transformWorkerEntrypoint, + findExportedFunctions, + shouldTransformWorker, + generateRpcInterface, + type ExportedFunction, + type WorkerTransformOptions, + type WorkerTransformResult +} from './worker-entrypoint' diff --git a/packages/devflare/src/transform/worker-entrypoint.ts b/packages/devflare/src/transform/worker-entrypoint.ts new file mode 100644 index 0000000..3e55e0c --- /dev/null +++ b/packages/devflare/src/transform/worker-entrypoint.ts @@ -0,0 +1,495 @@ +// ============================================================================= +// Worker Entrypoint Transform โ€” Transforms worker.ts exports to WorkerEntrypoint +// ============================================================================= +// Transforms a worker.ts file that exports multiple handlers (fetch, RPC methods) +// into a WorkerEntrypoint class that Cloudflare Workers can use. +// +// Uses TypeScript Compiler API for robust AST-based parsing. +// +// Input (worker.ts): +// export function fetch(request: Request, env: Env, ctx: ExecutionContext) { ... } +// export function add(a: number, b: number) { return a + b } +// export function multiply(a: number, b: number) { return a * b } +// +// Output: +// import { WorkerEntrypoint } from 'cloudflare:workers' +// class Worker extends WorkerEntrypoint { +// fetch(request: Request) { ... } +// add(a: number, b: number) { return a + b } +// multiply(a: number, b: number) { return a * b } +// } +// export { Worker as default } +// ============================================================================= + +import ts from 'typescript' +import MagicString from 'magic-string' +import { SUPPORTED_WORKER_EXTENSIONS, TS_WORKER_EXTENSIONS } from '../worker-entry/extensions' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * Information about an exported function + */ +export interface ExportedFunction { + /** Function name */ + name: string + /** Whether it's an async function */ + isAsync: boolean + /** Function parameters (as string) */ + params: string + /** Return type annotation (if any) */ + returnType?: string + /** Start position in source */ + start: number + /** End position in source */ + end: number + /** The full function text for preservation */ + body?: string + /** Whether this is the default export */ + isDefault?: boolean +} + +/** + * Options for worker entrypoint transformation + */ +export interface WorkerTransformOptions { + /** Class name to generate (default: 'Worker') */ + className?: string + /** Whether to inject context wrapper for fetch (default: true) */ + injectContext?: boolean +} + +/** + * Result of transformation + */ +export interface WorkerTransformResult { + code: string + map: ReturnType + /** List of RPC methods (exported functions except fetch) */ + rpcMethods: string[] + /** Generated class name */ + className: string +} + +// ----------------------------------------------------------------------------- +// AST-Based Detection using TypeScript Compiler API +// ----------------------------------------------------------------------------- + +/** + * Find all exported functions in a worker.ts file using TypeScript AST + */ +export function findExportedFunctions(code: string): ExportedFunction[] { + const functions: ExportedFunction[] = [] + + // Create a source file from the code + const sourceFile = ts.createSourceFile( + 'worker.ts', + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + + // Visit each node in the AST + function visit(node: ts.Node) { + // Handle: export function name(...) { } + if (ts.isFunctionDeclaration(node) && node.name) { + const modifiers = ts.getModifiers(node) + const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) + + if (isExported) { + const isAsync = modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false + const funcName = isDefault ? 'fetch' : node.name.text + const params = node.parameters + .map((p) => code.substring(p.getStart(sourceFile), p.getEnd())) + .join(', ') + const returnType = node.type + ? code.substring(node.type.getStart(sourceFile), node.type.getEnd()) + : undefined + + functions.push({ + name: funcName, + isAsync, + params, + returnType, + start: node.getStart(sourceFile), + end: node.getEnd(), + isDefault + }) + } + } + + // Handle: export default function(...) { } (anonymous default export) + if (ts.isFunctionDeclaration(node) && !node.name) { + const modifiers = ts.getModifiers(node) + const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) + + if (isExported && isDefault) { + const isAsync = modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false + const params = node.parameters + .map((p) => code.substring(p.getStart(sourceFile), p.getEnd())) + .join(', ') + const returnType = node.type + ? code.substring(node.type.getStart(sourceFile), node.type.getEnd()) + : undefined + + functions.push({ + name: 'fetch', + isAsync, + params, + returnType, + start: node.getStart(sourceFile), + end: node.getEnd(), + isDefault: true + }) + } + } + + // Handle: export const name = function(...) { } or export const name = (...) => { } + if (ts.isVariableStatement(node)) { + const modifiers = ts.getModifiers(node) + const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + + if (isExported) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer) { + let funcExpr: ts.FunctionExpression | ts.ArrowFunction | undefined + + if (ts.isFunctionExpression(decl.initializer)) { + funcExpr = decl.initializer + } else if (ts.isArrowFunction(decl.initializer)) { + funcExpr = decl.initializer + } + + if (funcExpr) { + const modifiersArr = ts.getModifiers(funcExpr) + const isAsync = modifiersArr?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) + ?? (ts.isArrowFunction(funcExpr) && funcExpr.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) + ?? false + const params = funcExpr.parameters + .map((p) => code.substring(p.getStart(sourceFile), p.getEnd())) + .join(', ') + const returnType = funcExpr.type + ? code.substring(funcExpr.type.getStart(sourceFile), funcExpr.type.getEnd()) + : undefined + + functions.push({ + name: decl.name.text, + isAsync, + params, + returnType, + start: node.getStart(sourceFile), + end: node.getEnd() + }) + } + } + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return functions +} + +/** + * Returns true when the filename has an extension that permits TS-only syntax. + * Gates injection of interfaces and type annotations into emitted worker code. + */ +export function shouldEmitTsSyntax(filename: string): boolean { + const lower = filename.toLowerCase() + return TS_WORKER_EXTENSIONS.some((ext) => lower.endsWith(ext)) +} + +/** + * Check if a file should be transformed as a worker entrypoint + * Returns true if the file has exported functions that could be RPC methods + */ +export function shouldTransformWorker(code: string, filePath: string): boolean { + const lower = filePath.toLowerCase() + const isWorkerFile = SUPPORTED_WORKER_EXTENSIONS.some((ext) => lower.endsWith(`worker${ext}`)) + if (!isWorkerFile) { + return false + } + + const functions = findExportedFunctions(code) + + // Need at least one function to transform + return functions.length > 0 +} + +// ----------------------------------------------------------------------------- +// Transformation +// ----------------------------------------------------------------------------- + +/** + * Transform a worker.ts file into a WorkerEntrypoint class + * + * @param code - Source code to transform + * @param id - File path/id for source mapping + * @param options - Transform options + * @returns Transformed code with source map + */ +export function transformWorkerEntrypoint( + code: string, + id: string, + options: WorkerTransformOptions = {} +): WorkerTransformResult | null { + const className = options.className ?? 'Worker' + const injectContext = options.injectContext ?? true + const emitTs = shouldEmitTsSyntax(id) + + const functions = findExportedFunctions(code) + + if (functions.length === 0) { + return null + } + + // Separate fetch from RPC methods + const fetchFn = functions.find((f) => f.name === 'fetch') + const rpcMethods = functions.filter((f) => f.name !== 'fetch') + const needsFetchHelpers = Boolean(fetchFn) + + const s = new MagicString(code) + + // Add WorkerEntrypoint import at the top + const importStatement = `import { WorkerEntrypoint } from 'cloudflare:workers'\n` + if (needsFetchHelpers) { + const runtimeImports = ['createFetchEvent', 'invokeFetchHandler'] + if (injectContext) { + runtimeImports.push('runWithEventContext') + } + s.prepend(`import { ${runtimeImports.join(', ')} } from 'devflare/runtime'\n`) + } + s.prepend(importStatement) + + // ------------------------------------------------------------------------- + // AST-based edits to rewrite exports โ†’ internal declarations. + // No regex/string replace is performed on already-parsed source; every + // mutation is keyed on AST node positions so comments and strings that + // happen to contain the matched pattern are never touched. + // ------------------------------------------------------------------------- + const sourceFile = ts.createSourceFile( + 'worker.ts', + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + + const internalNameFor = (name: string): string => + name === 'fetch' ? '__originalFetch' : `__original_${name}` + + function visit(node: ts.Node): void { + if (ts.isFunctionDeclaration(node)) { + const modifiers = ts.getModifiers(node) + const exportMod = modifiers?.find((m) => m.kind === ts.SyntaxKind.ExportKeyword) + const defaultMod = modifiers?.find((m) => m.kind === ts.SyntaxKind.DefaultKeyword) + const asyncMod = modifiers?.find((m) => m.kind === ts.SyntaxKind.AsyncKeyword) + + if (exportMod) { + const isDefault = Boolean(defaultMod) + const logicalName = isDefault ? 'fetch' : node.name?.text + if (logicalName) { + const internal = internalNameFor(logicalName) + + if (isDefault && !node.name) { + // Anonymous default: `export default (async )?function ...` + // Overwrite [node start, function-keyword start) with `const X = (async )?` + const funcKeyword = node + .getChildren(sourceFile) + .find((c) => c.kind === ts.SyntaxKind.FunctionKeyword) + if (funcKeyword) { + const asyncKw = asyncMod ? 'async ' : '' + s.overwrite( + node.getStart(sourceFile), + funcKeyword.getStart(sourceFile), + `const ${internal} = ${asyncKw}` + ) + } + } else if (node.name) { + // Named export: remove export (and default) modifiers, rename identifier + s.remove(exportMod.getStart(sourceFile), exportMod.getEnd()) + if (defaultMod) { + s.remove(defaultMod.getStart(sourceFile), defaultMod.getEnd()) + } + s.overwrite( + node.name.getStart(sourceFile), + node.name.getEnd(), + internal + ) + } + } + } + } else if (ts.isVariableStatement(node)) { + const modifiers = ts.getModifiers(node) + const exportMod = modifiers?.find((m) => m.kind === ts.SyntaxKind.ExportKeyword) + if (exportMod) { + let rewroteAny = false + for (const decl of node.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) + && decl.initializer + && (ts.isFunctionExpression(decl.initializer) + || ts.isArrowFunction(decl.initializer)) + ) { + const internal = internalNameFor(decl.name.text) + s.overwrite( + decl.name.getStart(sourceFile), + decl.name.getEnd(), + internal + ) + rewroteAny = true + } + } + // Only drop the `export` keyword when at least one declarator + // was rewritten into an internal name. Non-function exports + // (e.g. `export const VERSION = '1.0.0'`) are preserved as-is. + if (rewroteAny) { + s.remove(exportMod.getStart(sourceFile), exportMod.getEnd()) + } + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + // Build the class body + let classBody = `\n\n// ============ Devflare WorkerEntrypoint ============\nclass ${className} extends WorkerEntrypoint {\n` + + // Add fetch method if present + if (fetchFn) { + const fetchSig = emitTs + ? 'async fetch(request: Request): Promise' + : 'async fetch(request)' + classBody += `\t${fetchSig} {\n` + classBody += `\t\tconst __devflareEvent = createFetchEvent(request, this.env, this.ctx)\n` + + if (injectContext) { + classBody += `\t\treturn runWithEventContext(\n` + classBody += `\t\t\t__devflareEvent,\n` + classBody += `\t\t\t() => invokeFetchHandler(__originalFetch, __devflareEvent)\n` + classBody += `\t\t)\n` + classBody += `\t}\n` + } else { + classBody += `\t\treturn invokeFetchHandler(__originalFetch, __devflareEvent)\n` + classBody += `\t}\n` + } + } + + // Add RPC methods. For JS outputs, strip TS-only type annotations from + // the method signature (the backing __original_* function still receives + // whatever the user wrote, which for valid JS is always untyped). + for (const fn of rpcMethods) { + const asyncPrefix = fn.isAsync ? 'async ' : '' + const paramNames = extractParamNames(fn.params) + const signatureParams = emitTs ? fn.params : paramNames + const returnType = emitTs && fn.returnType ? `: ${fn.returnType}` : '' + + classBody += `\n\t${asyncPrefix}${fn.name}(${signatureParams})${returnType} {\n` + classBody += `\t\treturn __original_${fn.name}(${paramNames})\n` + classBody += `\t}\n` + } + + classBody += `}\n\n` + // Export the class both as named (for entrypoint) and as default (for worker) + classBody += `export { ${className} }\n` + classBody += `export { ${className} as default }\n` + classBody += `// ============ End Devflare WorkerEntrypoint ============\n` + + // Append the class at the end + s.append(classBody) + + return { + code: s.toString(), + map: s.generateMap({ + source: id, + file: id + '.map', + includeContent: true + }), + rpcMethods: rpcMethods.map((fn) => fn.name), + className + } +} + +/** + * Extract parameter names from a parameter string + * "a: number, b: string" -> "a, b" + */ +function extractParamNames(params: string): string { + if (!params.trim()) return '' + + // Parse using TypeScript to handle complex cases + const tempCode = `function f(${params}) {}` + const sourceFile = ts.createSourceFile( + 'temp.ts', + tempCode, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + + const names: string[] = [] + + function visit(node: ts.Node) { + if (ts.isFunctionDeclaration(node)) { + for (const param of node.parameters) { + if (ts.isIdentifier(param.name)) { + names.push(param.name.text) + } else if (ts.isObjectBindingPattern(param.name) || ts.isArrayBindingPattern(param.name)) { + // For destructured params, we need the full pattern + names.push(tempCode.substring(param.name.getStart(sourceFile), param.name.getEnd())) + } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return names.join(', ') +} + +// ----------------------------------------------------------------------------- +// Type Generation +// ----------------------------------------------------------------------------- + +/** + * Generate TypeScript interface for RPC methods + * This is useful for creating type-safe service binding contracts + */ +export function generateRpcInterface( + functions: ExportedFunction[], + interfaceName: string +): string { + const rpcMethods = functions.filter((f) => f.name !== 'fetch') + + if (rpcMethods.length === 0) { + return '' + } + + let output = `export interface ${interfaceName} {\n` + + for (const fn of rpcMethods) { + const returnType = fn.returnType ?? 'unknown' + // All RPC methods return Promises at the service binding level + const promiseReturn = returnType.startsWith('Promise<') + ? returnType + : `Promise<${returnType}>` + + output += `\t${fn.name}(${fn.params}): ${promiseReturn}\n` + } + + output += `}\n` + + return output +} diff --git a/packages/devflare/src/utils/entrypoint-discovery.ts b/packages/devflare/src/utils/entrypoint-discovery.ts new file mode 100644 index 0000000..7f94647 --- /dev/null +++ b/packages/devflare/src/utils/entrypoint-discovery.ts @@ -0,0 +1,128 @@ +// ============================================================================= +// Entrypoint Discovery โ€” Shared utilities for finding WorkerEntrypoint classes +// ============================================================================= +// Used by: +// - CLI types command (async, glob pattern) +// - Test context bundling (sync, single directory) +// ============================================================================= + +import { readFileSync } from 'fs' +import { findFiles, findFilesSync, DEFAULT_ENTRYPOINT_PATTERN } from './glob' + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * Information about a discovered entrypoint class + */ +export interface DiscoveredEntrypoint { + className: string + filePath: string +} + +// ----------------------------------------------------------------------------- +// Core Discovery Logic +// ----------------------------------------------------------------------------- + +/** + * Regex pattern to find WorkerEntrypoint class exports + * Matches: export class ClassName extends WorkerEntrypoint + */ +const ENTRYPOINT_CLASS_PATTERN = /export\s+class\s+(\w+)\s+extends\s+WorkerEntrypoint/g + +/** + * Find WorkerEntrypoint classes in source code + * @param code - Source code to search + * @returns Array of class names found + */ +export function findEntrypointClasses(code: string): string[] { + const classes: string[] = [] + + // Reset regex state for reuse + ENTRYPOINT_CLASS_PATTERN.lastIndex = 0 + + let match + while ((match = ENTRYPOINT_CLASS_PATTERN.exec(code)) !== null) { + classes.push(match[1]) + } + + return classes +} + +// ----------------------------------------------------------------------------- +// Directory Discovery (Sync) +// ----------------------------------------------------------------------------- + +/** + * Discover entrypoint classes from a glob pattern synchronously. + * Respects `.gitignore` automatically. + * + * @param cwd - Working directory for glob resolution + * @param pattern - Glob pattern for entrypoint files (default: recursive `ep.*.{ts,js}` matching) + * @returns Array of discovered entrypoints + */ +export function discoverEntrypointsSync( + cwd: string, + pattern = DEFAULT_ENTRYPOINT_PATTERN +): DiscoveredEntrypoint[] { + const discovered: DiscoveredEntrypoint[] = [] + + try { + const files = findFilesSync(pattern, { cwd }) + + for (const file of files) { + try { + const code = readFileSync(file, 'utf-8') + const classNames = findEntrypointClasses(code) + + for (const className of classNames) { + discovered.push({ className, filePath: file }) + } + } catch { + // Skip files that can't be read + } + } + } catch { + // Glob failed โ€” return empty result + } + + return discovered +} + +// ----------------------------------------------------------------------------- +// Glob Discovery (Async) +// ----------------------------------------------------------------------------- + +/** + * Discover entrypoint classes from ep.*.ts files using glob pattern (async). + * Respects .gitignore automatically. + * + * @param cwd - Working directory for glob + * @param pattern - Glob pattern for ep.*.ts files (default: **โ€‹/ep.*.{ts,js}) + * @returns Array of discovered entrypoints + */ +export async function discoverEntrypointsAsync( + cwd: string, + pattern = DEFAULT_ENTRYPOINT_PATTERN +): Promise { + const fs = await import('node:fs/promises') + const discovered: DiscoveredEntrypoint[] = [] + + const files = await findFiles(pattern, { cwd }) + + for (const filePath of files) { + try { + const code = await fs.readFile(filePath, 'utf-8') + const classNames = findEntrypointClasses(code) + + for (const className of classNames) { + discovered.push({ className, filePath }) + } + } catch { + // Skip files that can't be read + } + } + + return discovered +} diff --git a/packages/devflare/src/utils/glob.ts b/packages/devflare/src/utils/glob.ts new file mode 100644 index 0000000..0114fad --- /dev/null +++ b/packages/devflare/src/utils/glob.ts @@ -0,0 +1,96 @@ +// ============================================================================= +// Glob Utilities โ€” Gitignore-aware file matching +// ============================================================================= +// Uses globby for fast file discovery with automatic .gitignore support. +// All glob patterns in devflare should use these utilities. +// ============================================================================= + +import { globby, globbySync } from 'globby' + +// ----------------------------------------------------------------------------- +// Default Patterns +// ----------------------------------------------------------------------------- + +/** Default glob pattern for Durable Object discovery */ +export const DEFAULT_DO_PATTERN = '**/do.*.{ts,js}' + +/** Default glob pattern for WorkerEntrypoint discovery */ +export const DEFAULT_ENTRYPOINT_PATTERN = '**/ep.*.{ts,js}' + +/** Default glob pattern for Workflow discovery */ +export const DEFAULT_WORKFLOW_PATTERN = '**/wf.*.{ts,js}' + +// ----------------------------------------------------------------------------- +// Glob Options +// ----------------------------------------------------------------------------- + +export interface GlobOptions { + /** Working directory for glob pattern */ + cwd: string + /** Return absolute paths instead of relative */ + absolute?: boolean + /** Respect .gitignore files (default: true) */ + gitignore?: boolean +} + +// ----------------------------------------------------------------------------- +// Glob Functions +// ----------------------------------------------------------------------------- + +/** + * Find files matching a glob pattern with .gitignore support. + * This is the async version for use in CLI commands and bundlers. + * + * @param pattern - Glob pattern (e.g., '**โ€‹/do.*.{ts,js}') + * @param options - Glob options + * @returns Array of matching file paths + */ +export async function findFiles( + pattern: string | string[], + options: GlobOptions +): Promise { + const { cwd, absolute = true, gitignore = true } = options + + return globby(pattern, { + cwd, + absolute, + gitignore, + // Additional ignore patterns for common non-source directories + // These are fallbacks in case no .gitignore exists + ignore: [ + '**/node_modules/**', + '**/.devflare/**', + '**/dist/**', + '**/build/**', + '**/.git/**' + ] + }) +} + +/** + * Find files matching a glob pattern synchronously. + * Use sparingly โ€” prefer async version for better performance. + * + * @param pattern - Glob pattern (e.g., '**โ€‹/do.*.{ts,js}') + * @param options - Glob options + * @returns Array of matching file paths + */ +export function findFilesSync( + pattern: string | string[], + options: GlobOptions +): string[] { + const { cwd, absolute = true, gitignore = true } = options + + return globbySync(pattern, { + cwd, + absolute, + gitignore, + ignore: [ + '**/node_modules/**', + '**/.devflare/**', + '**/dist/**', + '**/build/**', + '**/.git/**' + ] + }) +} diff --git a/packages/devflare/src/utils/resolve-package.ts b/packages/devflare/src/utils/resolve-package.ts new file mode 100644 index 0000000..c350590 --- /dev/null +++ b/packages/devflare/src/utils/resolve-package.ts @@ -0,0 +1,117 @@ +// ============================================================================= +// Package Specifier Resolution โ€” Resolves package specifiers to filesystem paths +// ============================================================================= + +import { resolve, dirname } from 'pathe' +import { readFileSync, existsSync } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createRequire } from 'node:module' + +const NOT_FOUND_CODES = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']) + +function isNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const code = (error as { code?: unknown }).code + return typeof code === 'string' && NOT_FOUND_CODES.has(code) +} + +/** + * Resolve `specifier` from `fromDir` using ESM-first resolution. + * + * Chain: + * 1. `import.meta.resolve` (Bun exposes this synchronously for ESM packages) + * 2. `createRequire(fromDir).resolve` (CommonJS fallback) + * + * Only swallows MODULE_NOT_FOUND / ERR_MODULE_NOT_FOUND. Any other error + * (syntax, permission, etc.) is re-thrown. + */ +function resolveSpecifier(specifier: string, fromDir: string): string | null { + const fromFileUrl = pathToFileURL(resolve(fromDir, 'package.json')).href + + const importMetaResolve = (import.meta as { resolve?: (s: string, p?: string) => string }).resolve + if (typeof importMetaResolve === 'function') { + try { + const resolved = importMetaResolve(specifier, fromFileUrl) + if (typeof resolved === 'string') { + return resolved.startsWith('file:') ? fileURLToPath(resolved) : resolved + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + // fall through to createRequire fallback + } + } + + try { + const require_ = createRequire(fromFileUrl) + return require_.resolve(specifier) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + throw error + } +} + +/** + * Resolve a package specifier to a filesystem path + * Handles workspace packages like '@devflare/case11-do-shared/devflare.config' + * + * @param specifier - Package specifier (e.g., '@scope/pkg/path' or './relative/path') + * @param fromDir - Directory to resolve relative paths from + * @returns Resolved filesystem path + */ +export function resolvePackageSpecifier(specifier: string, fromDir: string): string { + // If it's a relative or absolute path, resolve normally + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[A-Za-z]:/.test(specifier)) { + return resolve(fromDir, specifier) + } + + // For scoped packages like @scope/pkg/subpath, we need to find the package root + // and then navigate to the subpath + const parts = specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') // @scope/pkg + : specifier.split('/')[0] // pkg + + const subpath = specifier.startsWith('@') + ? specifier.split('/').slice(2).join('/') // subpath after @scope/pkg + : specifier.split('/').slice(1).join('/') // subpath after pkg + + // Try to find the package's package.json via ESM-first resolution. + // A missing package falls back to a path-based guess to preserve + // historical behavior callers depend on; other errors (syntax, + // permission, etc.) propagate from resolveSpecifier(). + const pkgJsonPath = resolveSpecifier(`${parts}/package.json`, fromDir) + if (!pkgJsonPath) { + return resolve(fromDir, specifier) + } + + const pkgDir = dirname(pkgJsonPath) + + if (subpath) { + // Read package.json to check exports + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) + const exportPath = pkgJson.exports?.[`./${subpath}`] + + if (exportPath) { + // Handle export map (can be string or object) + const targetPath = typeof exportPath === 'string' ? exportPath : exportPath.default || exportPath.import + return resolve(pkgDir, targetPath) + } + + // Fallback: try direct path resolution + const directPath = resolve(pkgDir, `${subpath}.ts`) + if (existsSync(directPath)) return directPath + + const withExt = resolve(pkgDir, subpath) + if (existsSync(withExt)) return withExt + if (existsSync(`${withExt}.ts`)) return `${withExt}.ts` + if (existsSync(`${withExt}.js`)) return `${withExt}.js` + } + + return pkgDir +} diff --git a/packages/devflare/src/utils/send-email.ts b/packages/devflare/src/utils/send-email.ts new file mode 100644 index 0000000..771aaa9 --- /dev/null +++ b/packages/devflare/src/utils/send-email.ts @@ -0,0 +1,346 @@ +const RAW_EMAIL = 'EmailMessage::raw' + +type ComposedSendEmailMessage = { + from: string + to: string | string[] + subject?: string + replyTo?: string | EmailAddress + cc?: string | string[] + bcc?: string | string[] + headers?: Record + text?: string + html?: string + raw?: unknown +} + +export interface LocalSendEmailBindingConfig { + destinationAddress?: string + allowedDestinationAddresses?: string[] + allowedSenderAddresses?: string[] +} + +const wrappedSendEmailBindings = new WeakMap() +const wrappedEnvBindings = new WeakMap() +const localSendEmailBindings = new Map() + +function hasOwn(value: T, key: PropertyKey): key is keyof T { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isSendEmailBinding(value: unknown): value is SendEmail { + if (!isRecord(value)) { + return false + } + + try { + return typeof value.send === 'function' + && typeof value.sendBatch !== 'function' + } catch { + return false + } +} + +function isComposableSendEmailMessage(message: unknown): message is ComposedSendEmailMessage { + return isRecord(message) + && typeof message.from === 'string' + && (typeof message.to === 'string' || Array.isArray(message.to)) +} + +function formatEmailAddress(value: string | EmailAddress | undefined): string | undefined { + if (!value) { + return undefined + } + + return typeof value === 'string' ? value : String(value) +} + +function formatEmailList(value: string | string[] | undefined): string | undefined { + if (!value) { + return undefined + } + + return Array.isArray(value) ? value.join(', ') : value +} + +function normalizeBodyText(value: string): string { + return value.replace(/\r?\n/g, '\r\n') +} + +function buildMultipartAlternativeBody(message: ComposedSendEmailMessage, boundary: string): string { + const parts: string[] = [] + + if (message.text) { + parts.push( + `--${boundary}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + normalizeBodyText(message.text) + ) + } + + if (message.html) { + parts.push( + `--${boundary}`, + 'Content-Type: text/html; charset=UTF-8', + '', + normalizeBodyText(message.html) + ) + } + + parts.push(`--${boundary}--`) + return parts.join('\r\n') +} + +function buildRawEmail(message: ComposedSendEmailMessage): string { + const lines: string[] = [] + const messageId = `<${Date.now()}-${Math.random().toString(36).slice(2)}@devflare.dev>` + + lines.push(`From: ${message.from}`) + lines.push(`To: ${formatEmailList(message.to)}`) + lines.push(`Date: ${new Date().toUTCString()}`) + lines.push(`Message-ID: ${messageId}`) + + if (message.subject) { + lines.push(`Subject: ${message.subject}`) + } + + const replyTo = formatEmailAddress(message.replyTo) + if (replyTo) { + lines.push(`Reply-To: ${replyTo}`) + } + + const cc = formatEmailList(message.cc) + if (cc) { + lines.push(`Cc: ${cc}`) + } + + const bcc = formatEmailList(message.bcc) + if (bcc) { + lines.push(`Bcc: ${bcc}`) + } + + if (message.headers) { + for (const [key, value] of Object.entries(message.headers)) { + lines.push(`${key}: ${value}`) + } + } + + lines.push('MIME-Version: 1.0') + + if (message.text && message.html) { + const boundary = `devflare-alt-${crypto.randomUUID()}` + lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`) + lines.push('') + lines.push(buildMultipartAlternativeBody(message, boundary)) + return lines.join('\r\n') + } + + lines.push(`Content-Type: ${message.html ? 'text/html' : 'text/plain'}; charset=UTF-8`) + lines.push('') + lines.push(normalizeBodyText(message.html ?? message.text ?? '')) + + return lines.join('\r\n') +} + +export function createEmailMessageRaw(raw: unknown): string | ReadableStream { + if (typeof raw === 'string' || raw instanceof ReadableStream) { + return raw + } + + if (raw instanceof Uint8Array) { + const copy = new Uint8Array(raw.byteLength) + copy.set(raw) + return new Blob([copy]).stream() + } + + if (raw instanceof ArrayBuffer) { + return new Blob([new Uint8Array(raw.slice(0))]).stream() + } + + throw new Error('Unsupported EmailMessage raw payload') +} + +export function normalizeSendEmailMessage( + message: unknown +): Parameters[0] { + if (!isComposableSendEmailMessage(message)) { + return message as Parameters[0] + } + + if (hasOwn(message, RAW_EMAIL)) { + return message as Parameters[0] + } + + if (hasOwn(message, 'raw') && message.raw !== undefined) { + return { + from: message.from, + to: message.to, + [RAW_EMAIL]: createEmailMessageRaw(message.raw) + } as unknown as Parameters[0] + } + + return { + from: message.from, + to: message.to, + [RAW_EMAIL]: createEmailMessageRaw(buildRawEmail(message)) + } as unknown as Parameters[0] +} + +export function wrapSendEmailBinding(binding: SendEmail): SendEmail { + const cached = wrappedSendEmailBindings.get(binding) + if (cached) { + return cached + } + + const wrapped = new Proxy(binding, { + get(target, prop, receiver) { + if (prop === 'send') { + return async (message: unknown) => target.send(normalizeSendEmailMessage(message)) + } + + const value = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + } + }) as SendEmail + + wrappedSendEmailBindings.set(binding, wrapped) + return wrapped +} + +export function createLocalSendEmailBinding( + config: LocalSendEmailBindingConfig = {}, + options: { + onSend?: (message: Parameters[0]) => void | Promise + } = {} +): SendEmail { + return { + async send(message: unknown): Promise { + const normalized = normalizeSendEmailMessage(message) + + if (isRecord(normalized)) { + const from = typeof normalized.from === 'string' ? normalized.from : undefined + const recipients = Array.isArray(normalized.to) + ? normalized.to.filter((value): value is string => typeof value === 'string') + : typeof normalized.to === 'string' + ? [normalized.to] + : [] + + if ( + from + && config.allowedSenderAddresses + && !config.allowedSenderAddresses.includes(from) + ) { + throw new Error(`email from ${from} not allowed`) + } + + for (const recipient of recipients) { + if ( + config.destinationAddress !== undefined + && recipient !== config.destinationAddress + ) { + throw new Error(`email to ${recipient} not allowed`) + } + + if ( + config.allowedDestinationAddresses !== undefined + && !config.allowedDestinationAddresses.includes(recipient) + ) { + throw new Error(`email to ${recipient} not allowed`) + } + } + } + + await options.onSend?.(normalized) + return undefined as unknown as EmailSendResult + } + } as SendEmail +} + +export function setLocalSendEmailBindings( + bindings: Record +): void { + localSendEmailBindings.clear() + + for (const [name, config] of Object.entries(bindings)) { + localSendEmailBindings.set(name, createLocalSendEmailBinding(config)) + } +} + +export function clearLocalSendEmailBindings(): void { + localSendEmailBindings.clear() +} + +function needsEnvSendEmailWrapping(env: Record): boolean { + if (localSendEmailBindings.size > 0) { + return true + } + + for (const key of Reflect.ownKeys(env)) { + const value = Reflect.get(env, key) + if (isSendEmailBinding(value)) { + return true + } + } + + return false +} + +export function wrapEnvSendEmailBindings(env: TEnv): TEnv { + if (!isRecord(env)) { + return env + } + + if (!needsEnvSendEmailWrapping(env)) { + return env + } + + const cached = wrappedEnvBindings.get(env) + if (cached) { + return cached as TEnv + } + + const wrapped = new Proxy(env, { + get(target, prop, receiver) { + if (typeof prop === 'string' && localSendEmailBindings.has(prop)) { + return localSendEmailBindings.get(prop) + } + + const value = Reflect.get(target, prop, receiver) + return isSendEmailBinding(value) ? wrapSendEmailBinding(value) : value + }, + has(target, prop) { + return Reflect.has(target, prop) + || (typeof prop === 'string' && localSendEmailBindings.has(prop)) + }, + ownKeys(target) { + return Array.from(new Set([ + ...Reflect.ownKeys(target), + ...localSendEmailBindings.keys() + ])) + }, + getOwnPropertyDescriptor(target, prop) { + if (typeof prop === 'string' && localSendEmailBindings.has(prop)) { + return { + configurable: true, + enumerable: true, + writable: false, + value: localSendEmailBindings.get(prop) + } + } + + const descriptor = Reflect.getOwnPropertyDescriptor(target, prop) + if (descriptor) { + return descriptor + } + + return undefined + } + }) + + wrappedEnvBindings.set(env, wrapped) + return wrapped as TEnv +} diff --git a/packages/devflare/src/vite/config-file.ts b/packages/devflare/src/vite/config-file.ts new file mode 100644 index 0000000..f586950 --- /dev/null +++ b/packages/devflare/src/vite/config-file.ts @@ -0,0 +1,219 @@ +import type { ConfigEnv, Plugin, PluginOption, UserConfig } from 'vite' +import { loadConfig, type DevflareConfig } from '../config' +import { resolveConfigForEnvironment } from '../config/resolve' +import { type ViteProjectDetection } from '../dev-server/vite-utils' +import { devflarePlugin, type DevflarePluginOptions } from './plugin' + +const CONFIG_DIR = '.devflare' +const GENERATED_VITE_CONFIG_FILENAME = 'vite.config.mjs' + +export interface EffectiveViteProjectDetection extends ViteProjectDetection { + hasDevflareViteConfig: boolean + shouldStartVite: boolean + wantsViteIntegration: boolean +} + +export function hasInlineViteConfig(viteConfig: DevflareConfig['vite'] | undefined): boolean { + return Boolean(viteConfig && Object.keys(viteConfig).length > 0) +} + +export function resolveEffectiveViteProject( + detection: ViteProjectDetection, + config: DevflareConfig, + environment?: string +): EffectiveViteProjectDetection { + const resolvedConfig = resolveConfigForEnvironment(config, environment) + const hasDevflareConfig = hasInlineViteConfig(resolvedConfig.vite) + + return { + ...detection, + hasDevflareViteConfig: hasDevflareConfig, + shouldStartVite: detection.shouldStartVite || hasDevflareConfig, + wantsViteIntegration: detection.wantsViteIntegration || hasDevflareConfig + } +} + + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') + && value !== null + && typeof (value as PromiseLike).then === 'function' + ) +} + +function normalizePluginOptions( + pluginOption: PluginOption | PluginOption[] | undefined +): PluginOption[] { + if (typeof pluginOption === 'undefined') { + return [] + } + + return Array.isArray(pluginOption) ? pluginOption : [pluginOption] +} + +function removePluginByName( + pluginOption: PluginOption, + pluginName: string +): PluginOption | undefined { + if (Array.isArray(pluginOption)) { + const filteredPlugins = pluginOption + .map((nestedPlugin) => removePluginByName(nestedPlugin, pluginName)) + .filter((nestedPlugin): nestedPlugin is PluginOption => typeof nestedPlugin !== 'undefined') + + return filteredPlugins.length > 0 ? filteredPlugins : undefined + } + + if (!pluginOption || typeof pluginOption === 'boolean' || isPromiseLike(pluginOption)) { + return pluginOption + } + + return (pluginOption as Plugin).name === pluginName ? undefined : pluginOption +} + +function withInjectedDevflarePlugin( + config: UserConfig, + pluginOptions: DevflarePluginOptions +): UserConfig { + const existingPlugins = normalizePluginOptions(config.plugins) + .map((pluginOption) => removePluginByName(pluginOption, 'devflare')) + .filter((pluginOption): pluginOption is PluginOption => typeof pluginOption !== 'undefined') + + return { + ...config, + plugins: [devflarePlugin(pluginOptions), ...existingPlugins] + } +} + +export async function resolveViteUserConfig( + configEnv: ConfigEnv, + options: { + cwd?: string + configPath?: string + environment?: string + localConfigPath?: string | null + bridgePort?: number + } = {} +): Promise { + // Lazy-load Vite at call time so Bun-running CLI commands like `devflare deploy` + // don't eagerly resolve the host app's Vite package during module initialization. + // The generated Vite config executes this path inside the actual Vite process. + const { loadConfigFromFile, mergeConfig } = await import('vite') + const cwd = options.cwd ?? process.cwd() + const devflareConfig = await loadConfig({ + cwd, + configFile: options.configPath + }) + const resolvedDevflareConfig = resolveConfigForEnvironment(devflareConfig, options.environment) + const inlineViteConfig = (resolvedDevflareConfig.vite ?? {}) as UserConfig + + const localConfig = options.localConfigPath + ? (await loadConfigFromFile(configEnv, options.localConfigPath, cwd))?.config ?? {} + : {} + + const mergedConfig = mergeConfig(localConfig, inlineViteConfig) + const normalizedConfig = mergedConfig.root + ? mergedConfig + : { + ...mergedConfig, + root: cwd + } + + return withInjectedDevflarePlugin(normalizedConfig, { + configPath: options.configPath, + environment: options.environment, + bridgePort: options.bridgePort + }) +} + +async function ensureGeneratedConfigDir(cwd: string): Promise { + const fs = await import('node:fs/promises') + const { resolve } = await import('pathe') + const configDir = resolve(cwd, CONFIG_DIR) + await fs.mkdir(configDir, { recursive: true }) + + const gitignorePath = resolve(configDir, '.gitignore') + try { + await fs.access(gitignorePath) + } catch { + await fs.writeFile(gitignorePath, '*\n', 'utf-8') + } + + return configDir +} + +async function resolveDevflarePackageRoot(currentFilePath: string): Promise { + const fs = await import('node:fs/promises') + const { dirname, resolve } = await import('pathe') + let currentDir = dirname(currentFilePath) + + while (true) { + const packageJsonPath = resolve(currentDir, 'package.json') + + try { + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as { + name?: string + } + if (packageJson.name === 'devflare') { + return currentDir + } + } catch { + // Keep walking upward until we find the package root. + } + + const parentDir = dirname(currentDir) + if (parentDir === currentDir) { + break + } + + currentDir = parentDir + } + + throw new Error('Could not resolve the devflare package root for generated Vite config imports.') +} + +async function resolveGeneratedViteImportPath(configDir: string): Promise { + const { fileURLToPath } = await import('node:url') + const { extname, normalize, relative, resolve } = await import('pathe') + const currentFilePath = normalize(fileURLToPath(import.meta.url)) + const currentExtension = extname(currentFilePath) + const packageRoot = await resolveDevflarePackageRoot(currentFilePath) + const viteEntryPath = currentFilePath.includes('/dist/') + ? resolve(packageRoot, 'dist/vite/index.js') + : resolve(packageRoot, `src/vite/index${currentExtension}`) + const relativeImportPath = relative(configDir, viteEntryPath) + + return relativeImportPath.startsWith('.') + ? relativeImportPath + : `./${relativeImportPath}` +} + +export async function writeGeneratedViteConfig(options: { + cwd: string + configPath?: string + environment?: string + localConfigPath?: string | null + bridgePort?: number +}): Promise { + const fs = await import('node:fs/promises') + const { resolve } = await import('pathe') + const configDir = await ensureGeneratedConfigDir(options.cwd) + const generatedConfigPath = resolve(configDir, GENERATED_VITE_CONFIG_FILENAME) + const viteImportPath = await resolveGeneratedViteImportPath(configDir) + const content = `import { defineConfig } from 'vite' +import { resolveViteUserConfig } from ${JSON.stringify(viteImportPath)} + +export default defineConfig(async (env) => { + return await resolveViteUserConfig(env, { + cwd: ${JSON.stringify(options.cwd)}, + configPath: ${JSON.stringify(options.configPath)}, + environment: ${JSON.stringify(options.environment)}, + localConfigPath: ${JSON.stringify(options.localConfigPath)}, + bridgePort: ${JSON.stringify(options.bridgePort)} + }) +}) +` + + await fs.writeFile(generatedConfigPath, content, 'utf-8') + return generatedConfigPath +} \ No newline at end of file diff --git a/packages/devflare/src/vite/index.ts b/packages/devflare/src/vite/index.ts new file mode 100644 index 0000000..9816c48 --- /dev/null +++ b/packages/devflare/src/vite/index.ts @@ -0,0 +1,24 @@ +// ============================================================================= +// Vite Module โ€” Public Exports +// ============================================================================= + +export { + devflarePlugin, + getPluginContext, + getCloudflareConfig, + getDevflareConfigs, + type DevflarePluginOptions, + type DevflarePluginContext, + type AuxiliaryWorkerConfig, + type DODiscoveryResult +} from './plugin' +export { + hasInlineViteConfig, + resolveEffectiveViteProject, + resolveViteUserConfig, + writeGeneratedViteConfig, + type EffectiveViteProjectDetection +} from './config-file' + +// Re-export as default for convenience +export { devflarePlugin as default } from './plugin' diff --git a/packages/devflare/src/vite/plugin-config-hook.ts b/packages/devflare/src/vite/plugin-config-hook.ts new file mode 100644 index 0000000..f7f7419 --- /dev/null +++ b/packages/devflare/src/vite/plugin-config-hook.ts @@ -0,0 +1,123 @@ +// ============================================================================= +// Vite plugin โ€” `config()` hook helpers +// ============================================================================= +// Pure helpers used by the devflare Vite plugin's `config(config, { command })` +// hook to derive the additional Vite settings to merge in: +// - `define` injection for `__DEVFLARE_WORKER_NAME__` +// - `server.proxy` entries for WebSocket routes (DO connections) +// ============================================================================= + +import { loadConfig } from '../config/loader' +import type { DevflareConfig } from '../config/schema' + +/** + * Try to load the devflare config without throwing. Returns `null` if the + * config does not exist (the plugin still works without one). + */ +export async function tryLoadDevflareConfig( + cwd: string, + configPath: string | undefined, + command: 'serve' | 'build' +): Promise { + try { + return await loadConfig({ cwd, configFile: configPath }) + } catch (error) { + if (command === 'build') { + console.warn('[devflare] Could not load config:', error) + } + return null + } +} + +/** + * Build the `define` map injecting `__DEVFLARE_WORKER_NAME__` as a build-time + * constant. Caller is responsible for merging this into any existing `define`. + */ +export function buildWorkerNameDefine( + lfConfig: DevflareConfig, + existing: Record +): Record { + const workerNameValue = lfConfig.name ?? 'unknown' + return { + ...existing, + '__DEVFLARE_WORKER_NAME__': JSON.stringify(workerNameValue) + } +} + +/** + * Build the `server.proxy` config that forwards WebSocket upgrades to the + * Miniflare bridge. Returns `null` when no patterns are configured. + */ +export function buildWebSocketProxyConfig( + lfConfig: DevflareConfig, + bridgePort: number, + wsProxyPatterns: string[] +): Record | null { + const patterns: string[] = [...wsProxyPatterns] + + if (lfConfig.wsRoutes && lfConfig.wsRoutes.length > 0) { + for (const route of lfConfig.wsRoutes) { + if (!patterns.includes(route.pattern)) { + patterns.push(route.pattern) + } + } + } + + if (patterns.length === 0) return null + + const proxyConfig: Record = {} + + for (const pattern of patterns) { + proxyConfig[pattern] = { + target: `http://127.0.0.1:${bridgePort}`, + changeOrigin: true, + ws: true, + configure: (proxy: unknown) => { + ; (proxy as { on: (event: string, handler: (err: Error) => void) => void }) + .on('error', (err: Error) => { + console.error(`[devflare] Proxy error: ${err.message}`) + }) + } + } + } + + if (Object.keys(proxyConfig).length === 0) return null + + console.log(`[devflare] WebSocket proxy configured for: ${patterns.join(', ')}`) + return proxyConfig +} + +/** + * Build the additional Vite config returned by the plugin's `config()` hook. + * Loads the devflare config, derives `define` and (in dev under + * `DEVFLARE_DEV`) `server.proxy`. Returns `undefined` when there is nothing + * to merge. + */ +export async function buildPluginConfigHookResult( + cwd: string, + options: { + configPath: string | undefined + bridgePort: number | undefined + wsProxyPatterns: string[] + }, + command: 'serve' | 'build', + existingDefine: Record +): Promise | undefined> { + const lfConfig = await tryLoadDevflareConfig(cwd, options.configPath, command) + + const returnConfig: Record = {} + + if (lfConfig) { + returnConfig.define = buildWorkerNameDefine(lfConfig, existingDefine) + } + + if (command === 'serve' && process.env.DEVFLARE_DEV && lfConfig) { + const port = options.bridgePort ?? 8787 + const proxyConfig = buildWebSocketProxyConfig(lfConfig, port, options.wsProxyPatterns) + if (proxyConfig) { + returnConfig.server = { proxy: proxyConfig } + } + } + + return Object.keys(returnConfig).length > 0 ? returnConfig : undefined +} diff --git a/packages/devflare/src/vite/plugin-context.ts b/packages/devflare/src/vite/plugin-context.ts new file mode 100644 index 0000000..1800389 --- /dev/null +++ b/packages/devflare/src/vite/plugin-context.ts @@ -0,0 +1,198 @@ +// ============================================================================= +// Devflare Vite Plugin โ€” context builder + generated-config helpers +// ============================================================================= +// Extracted from vite/plugin.ts (F35 step 2). Pure helpers used by the plugin +// hooks to: +// - Compile a `DevflareConfig` into a wrangler config + cloudflare-plugin +// programmatic config + (optional) auxiliary DO worker config. +// - Manage the on-disk generated `.devflare/` directory. +// - Resolve the plugin's own config-file path on the user's project. +// ============================================================================= + +import { isAbsolute, relative, resolve } from 'pathe' +import { resolveConfigPath } from '../config/loader' +import { resolveConfigEnvVars, resolveResources } from '../config' +import { + compileBuildConfig, + compileConfig, + compileToProgrammaticConfig, + isolateViteBuildOutputPaths, + rebaseWranglerConfigPaths, + writeWranglerConfig, + type WranglerConfig +} from '../config/compiler' +import type { DevflareConfig } from '../config/schema' +import type { ResolvedConfig as ResolvedDevflareConfig } from '../config/resolve-phased' +import { DEFAULT_DO_PATTERN } from '../utils/glob' +import { prepareComposedWorkerEntrypoint } from '../worker-entry/composed-worker' +import { + createAuxiliaryWorkerConfig, + discoverDurableObjects, + type AuxiliaryWorkerConfig, + type DODiscoveryResult +} from './plugin-durable-objects' +import { resolveServiceBindings } from '../test/resolve-service-bindings' +import { createAuxiliaryServiceWorkerConfigs } from './plugin-service-bindings' + +const CONFIG_DIR = '.devflare' + +export interface ResolvedPluginContextState { + wranglerConfig: WranglerConfig + cloudflareConfig: Record + auxiliaryWorkerConfig: AuxiliaryWorkerConfig | null + auxiliaryWorkerConfigs: AuxiliaryWorkerConfig[] + serviceWorkerVirtualModules: Map + durableObjects: DODiscoveryResult | null +} + +function removeDevflareHandledServeBindings(config: Record): Record { + const next = { ...config } + + // Devflare supplies these locally through its own Miniflare gateway and + // SvelteKit platform shims. Passing them to @cloudflare/vite-plugin in + // serve mode only produces upstream remote/local-development warnings. + delete next.workflows + delete next.media + + return next +} + +/** + * Compile a DevflareConfig into the bundle of artefacts the Vite plugin + * exposes to consumers (wrangler config, cloudflare-plugin programmatic + * config, auxiliary DO worker, discovered DOs). + * + * Mode discriminator: + * - `'serve'` โ€” materialize names into stable local identifiers + * (miniflare-friendly). + * - `'build'` โ€” preserve names in the emitted Wrangler artefact so deploy + * can later resolve them against the real Cloudflare account. + */ +export async function buildPluginContextState( + projectRoot: string, + devflareConfig: DevflareConfig, + environment?: string, + mode: 'serve' | 'build' = 'serve', + configDir: string = projectRoot +): Promise { + const resourceResolvedConfig = mode === 'build' + ? await resolveResources(devflareConfig, { phase: 'build', environment }) + : await resolveResources(devflareConfig, { phase: 'local', environment }) + const effectiveConfig = await resolveConfigEnvVars(resourceResolvedConfig, { + cwd: configDir, + mode: mode === 'build' ? 'build' : 'dev' + }) + const compiledWranglerConfig = mode === 'build' + ? compileBuildConfig(effectiveConfig) + : compileConfig(effectiveConfig as ResolvedDevflareConfig) + const wranglerConfig = mode === 'build' + ? isolateViteBuildOutputPaths(projectRoot, compiledWranglerConfig) + : removeDevflareHandledServeBindings(compiledWranglerConfig) as WranglerConfig + const cloudflareConfig = { + ...(mode === 'build' + ? isolateViteBuildOutputPaths( + projectRoot, + compileToProgrammaticConfig(effectiveConfig, environment, { preserveNamedBindings: true }) as WranglerConfig + ) + : removeDevflareHandledServeBindings( + compileToProgrammaticConfig(effectiveConfig, environment) + )) + } + const composedMainEntry = mode === 'build' + ? null + : await prepareComposedWorkerEntrypoint(projectRoot, effectiveConfig, environment) + if (composedMainEntry) { + const relativeMain = relative(projectRoot, composedMainEntry) + wranglerConfig.main = relativeMain + cloudflareConfig.main = relativeMain + } + + let durableObjects: DODiscoveryResult | null = null + let auxiliaryWorkerConfig: AuxiliaryWorkerConfig | null = null + let serviceWorkerVirtualModules = new Map() + let serviceAuxiliaryWorkerConfigs: AuxiliaryWorkerConfig[] = [] + + const doPatternConfig = effectiveConfig.files?.durableObjects + const doPattern = typeof doPatternConfig === 'string' ? doPatternConfig : DEFAULT_DO_PATTERN + if (doPatternConfig !== false) { + const doWorkerName = `${wranglerConfig.name}-do` + const discovery = await discoverDurableObjects(projectRoot, doPattern, doWorkerName) + + if (discovery.files.size > 0) { + durableObjects = discovery + + if (wranglerConfig.durable_objects?.bindings) { + for (const binding of wranglerConfig.durable_objects.bindings) { + binding.script_name = doWorkerName + } + } + if (cloudflareConfig.durable_objects) { + const doConfig = cloudflareConfig.durable_objects as { bindings: Array<{ script_name?: string }> } + for (const binding of doConfig.bindings) { + binding.script_name = doWorkerName + } + } + + auxiliaryWorkerConfig = createAuxiliaryWorkerConfig(wranglerConfig, discovery) + } + } + + if (mode === 'serve' && effectiveConfig.bindings?.services) { + const serviceBindingResolution = await resolveServiceBindings(effectiveConfig, configDir) + const serviceWorkers = createAuxiliaryServiceWorkerConfigs(serviceBindingResolution) + serviceAuxiliaryWorkerConfigs = serviceWorkers.auxiliaryWorkers + serviceWorkerVirtualModules = serviceWorkers.virtualModules + } + + const auxiliaryWorkerConfigs = [ + ...(auxiliaryWorkerConfig ? [auxiliaryWorkerConfig] : []), + ...serviceAuxiliaryWorkerConfigs + ] + + return { + wranglerConfig, + cloudflareConfig, + auxiliaryWorkerConfigs, + serviceWorkerVirtualModules, + durableObjects, + auxiliaryWorkerConfig + } +} + +export async function ensureGeneratedConfigDir(projectRoot: string): Promise { + const configDir = resolve(projectRoot, CONFIG_DIR) + const fs = await import('node:fs/promises') + await fs.mkdir(configDir, { recursive: true }) + + const gitignorePath = resolve(configDir, '.gitignore') + try { + await fs.access(gitignorePath) + } catch { + await fs.writeFile(gitignorePath, '*\n', 'utf-8') + } + + return configDir +} + +export async function writeGeneratedWranglerConfig( + projectRoot: string, + wranglerConfig: WranglerConfig +): Promise { + const configDir = await ensureGeneratedConfigDir(projectRoot) + const wranglerFileConfig = rebaseWranglerConfigPaths(projectRoot, configDir, wranglerConfig) + + await writeWranglerConfig(configDir, wranglerFileConfig, 'wrangler.jsonc') +} + +export async function resolvePluginConfigPath( + projectRoot: string, + configPath?: string +): Promise { + if (configPath) { + return isAbsolute(configPath) + ? configPath + : resolve(projectRoot, configPath) + } + + return await resolveConfigPath(projectRoot) ?? null +} diff --git a/packages/devflare/src/vite/plugin-durable-objects.ts b/packages/devflare/src/vite/plugin-durable-objects.ts new file mode 100644 index 0000000..7654525 --- /dev/null +++ b/packages/devflare/src/vite/plugin-durable-objects.ts @@ -0,0 +1,94 @@ +// ============================================================================= +// Devflare Vite plugin โ€” Durable Object discovery + auxiliary worker helpers +// ============================================================================= +// Pure helpers extracted from `vite/plugin.ts`: +// - DO file/class discovery (re-exported from shared +// `worker-entry/durable-object-discovery.ts`) +// - Virtual DO entry module codegen (`generateVirtualDOEntry`) +// - Auxiliary worker config construction (`createAuxiliaryWorkerConfig`) +// - Diagnostic logging (`logDiscoveredDurableObjects`) +// +// `VIRTUAL_DO_ENTRY` is duplicated here so the helpers stay self-contained; +// `plugin.ts` re-exports the same constant for backwards compatibility. +// ============================================================================= + +import type { WranglerConfig } from '../config/compiler' +import { discoverDurableObjects, type DODiscoveryResult } from '../worker-entry/durable-object-discovery' + +export const VIRTUAL_DO_ENTRY = 'virtual:devflare-do-entry' +export const RESOLVED_VIRTUAL_DO_ENTRY = '\0' + VIRTUAL_DO_ENTRY + +// Re-exported from the shared helper so existing import paths keep working. +export { discoverDurableObjects, type DODiscoveryResult } + +export interface AuxiliaryWorkerConfig { + config: Record +} + +/** + * Generate virtual DO entry module code + */ +export function generateVirtualDOEntry(discovery: DODiscoveryResult): string { + const lines: string[] = [ + '// Auto-generated by devflare โ€” DO entry module', + '// Re-exports all Durable Object classes discovered from files.durableObjects pattern', + '' + ] + + for (const [filePath, classNames] of discovery.files) { + const normalizedPath = filePath.replace(/\\/g, '/') + lines.push(`export { ${classNames.join(', ')} } from '${normalizedPath}'`) + } + + lines.push('') + lines.push('// Default fetch handler for DO worker') + lines.push('export default {') + lines.push('\tasync fetch(request: Request): Promise {') + lines.push('\t\treturn new Response("Devflare DO Worker", { status: 200 })') + lines.push('\t}') + lines.push('}') + + return lines.join('\n') +} + +/** + * Create auxiliary worker config for Durable Objects + */ +export function createAuxiliaryWorkerConfig( + wranglerConfig: WranglerConfig, + discovery: DODiscoveryResult +): AuxiliaryWorkerConfig { + const doBindings = wranglerConfig.durable_objects?.bindings?.map((binding) => ({ + name: binding.name, + class_name: binding.class_name + })) ?? [] + + return { + config: { + name: discovery.workerName, + main: VIRTUAL_DO_ENTRY, + compatibility_date: wranglerConfig.compatibility_date, + compatibility_flags: wranglerConfig.compatibility_flags, + durable_objects: { bindings: doBindings }, + migrations: wranglerConfig.migrations, + kv_namespaces: wranglerConfig.kv_namespaces, + d1_databases: wranglerConfig.d1_databases, + r2_buckets: wranglerConfig.r2_buckets, + browser: wranglerConfig.browser + } + } +} + +export function logDiscoveredDurableObjects( + projectRoot: string, + discovery: DODiscoveryResult | null +): void { + if (!discovery || discovery.files.size === 0) { + return + } + + console.log(`[devflare] Discovered ${discovery.files.size} DO file(s):`) + for (const [filePath, classes] of discovery.files) { + console.log(` โ€ข ${filePath.replace(projectRoot, '.')} โ†’ ${classes.join(', ')}`) + } +} diff --git a/packages/devflare/src/vite/plugin-programmatic.ts b/packages/devflare/src/vite/plugin-programmatic.ts new file mode 100644 index 0000000..35b585a --- /dev/null +++ b/packages/devflare/src/vite/plugin-programmatic.ts @@ -0,0 +1,165 @@ +// ============================================================================= +// Vite plugin โ€” public programmatic config helpers +// ============================================================================= +// Standalone helpers that callers can use from `vite.config.ts` to feed +// `@cloudflare/vite-plugin` programmatically without instantiating the +// `devflarePlugin()` itself. These are independent of plugin state. +// ============================================================================= + +import { relative } from 'pathe' +import { + loadResolvedConfig, + resolveConfigEnvVars, + resolveResources +} from '../config' +import { loadConfig } from '../config/loader' +import { compileConfig, compileToProgrammaticConfig } from '../config/compiler' +import { DEFAULT_DO_PATTERN } from '../utils/glob' +import { prepareComposedWorkerEntrypoint } from '../worker-entry/composed-worker' +import { + createAuxiliaryWorkerConfig, + discoverDurableObjects, + type AuxiliaryWorkerConfig +} from './plugin-durable-objects' +import { resolveServiceBindings } from '../test/resolve-service-bindings' +import { createAuxiliaryServiceWorkerConfigs } from './plugin-service-bindings' + +interface ProgrammaticConfigOptions { + cwd?: string + configPath?: string + environment?: string + /** + * Resolution strategy for name-based KV/D1/Hyperdrive bindings. + * - `'offline-local'` (default) โ€” no network; use stable local identifiers + * - `'remote'` โ€” resolve against the live Cloudflare account (legacy) + */ + resolve?: 'offline-local' | 'remote' +} + +async function loadProgrammaticDevflareConfig(options: ProgrammaticConfigOptions) { + const cwd = options.cwd ?? process.cwd() + const strategy = options.resolve ?? 'offline-local' + const resourceResolvedConfig = strategy === 'remote' + ? await loadResolvedConfig({ + cwd, + configFile: options.configPath, + environment: options.environment + }) + : await resolveResources( + await loadConfig({ cwd, configFile: options.configPath }), + { phase: 'local', environment: options.environment } + ) + const devflareConfig = await resolveConfigEnvVars(resourceResolvedConfig, { + cwd, + configPath: options.configPath, + mode: strategy === 'remote' ? 'build' : 'dev' + }) + return { cwd, devflareConfig } +} + +interface ProgrammaticArtifacts { + cwd: string + devflareConfig: Awaited>['devflareConfig'] + composedMainEntry: string | null + wranglerConfig: ReturnType + cloudflareConfig: Record + auxiliaryWorkers: AuxiliaryWorkerConfig[] +} + +/** + * Single canonical builder for programmatic config artifacts. + * + * Both public helpers (`getCloudflareConfig`, `getDevflareConfigs`) project + * out of this. The function loads the devflare config, prepares the composed + * worker entrypoint, compiles the wrangler config, derives the matching + * cloudflare-vite-plugin config (with optional `programmatic` projection), + * and discovers auxiliary DO workers when applicable. + */ +async function buildProgrammaticArtifacts( + options: ProgrammaticConfigOptions, + mode: 'wrangler' | 'programmatic' +): Promise { + const { cwd, devflareConfig } = await loadProgrammaticDevflareConfig(options) + const composedMainEntry = await prepareComposedWorkerEntrypoint(cwd, devflareConfig) + + const wranglerConfig = compileConfig(devflareConfig) + const cloudflareConfig: Record = mode === 'programmatic' + ? compileToProgrammaticConfig(devflareConfig) + : { ...wranglerConfig } + + if (composedMainEntry) { + const relativeMain = relative(cwd, composedMainEntry) + wranglerConfig.main = relativeMain + cloudflareConfig.main = relativeMain + } + + const auxiliaryWorkers: AuxiliaryWorkerConfig[] = [] + + const doPatternConfig = devflareConfig.files?.durableObjects + const doPattern = typeof doPatternConfig === 'string' ? doPatternConfig : DEFAULT_DO_PATTERN + if (doPatternConfig !== false) { + const doWorkerName = `${wranglerConfig.name}-do` + const discovery = await discoverDurableObjects(cwd, doPattern, doWorkerName) + + if (discovery.files.size > 0) { + if (cloudflareConfig.durable_objects) { + const doConfig = cloudflareConfig.durable_objects as { bindings: Array<{ script_name?: string }> } + for (const binding of doConfig.bindings) { + binding.script_name = doWorkerName + } + } + auxiliaryWorkers.push(createAuxiliaryWorkerConfig(wranglerConfig, discovery)) + } + } + + if (devflareConfig.bindings?.services) { + const serviceBindingResolution = await resolveServiceBindings(devflareConfig, cwd) + auxiliaryWorkers.push( + ...createAuxiliaryServiceWorkerConfigs(serviceBindingResolution).auxiliaryWorkers + ) + } + + return { cwd, devflareConfig, composedMainEntry, wranglerConfig, cloudflareConfig, auxiliaryWorkers } +} + +/** + * Get cloudflare config for programmatic use with @cloudflare/vite-plugin. + * Call this in vite.config.ts before setting up plugins. + * + * By default the config is resolved **offline** using local stable + * identifiers (no Cloudflare credentials required โ€” matches the Miniflare / + * workerd behaviour of `vite dev`). Pass `{ resolve: 'remote' }` to restore + * the legacy behaviour that talks to the Cloudflare API and fails without + * credentials (e.g. when you want the programmatic config to reflect real + * production IDs during an automation script). + */ +export async function getCloudflareConfig( + options: ProgrammaticConfigOptions = {} +): Promise> { + const { cloudflareConfig } = await buildProgrammaticArtifacts(options, 'programmatic') + return cloudflareConfig +} + +/** + * Get auxiliary worker configs for Durable Objects + * Use this when configuring @cloudflare/vite-plugin's auxiliaryWorkers option + * + * @example + * ```ts + * const { cloudflareConfig, auxiliaryWorkers } = await getDevflareConfigs() + * + * cloudflare({ + * config: cloudflareConfig, + * auxiliaryWorkers + * }) + * ``` + */ +export async function getDevflareConfigs( + options: ProgrammaticConfigOptions = {} +): Promise<{ + cloudflareConfig: Record + auxiliaryWorkers: AuxiliaryWorkerConfig[] +}> { + const { cloudflareConfig, auxiliaryWorkers } = await buildProgrammaticArtifacts(options, 'wrangler') + return { cloudflareConfig, auxiliaryWorkers } +} diff --git a/packages/devflare/src/vite/plugin-service-bindings.ts b/packages/devflare/src/vite/plugin-service-bindings.ts new file mode 100644 index 0000000..82d6fbf --- /dev/null +++ b/packages/devflare/src/vite/plugin-service-bindings.ts @@ -0,0 +1,135 @@ +import type { WranglerConfig } from '../config/compiler' +import type { ResolvedWorker, resolveServiceBindings } from '../test/resolve-service-bindings' +import type { AuxiliaryWorkerConfig } from './plugin-durable-objects' + +export const VIRTUAL_SERVICE_WORKER_PREFIX = 'virtual:devflare-service-worker/' +export const RESOLVED_VIRTUAL_SERVICE_WORKER_PREFIX = '\0' + VIRTUAL_SERVICE_WORKER_PREFIX + +type ServiceBindingResolution = Awaited> + +export interface AuxiliaryServiceWorkerResult { + auxiliaryWorkers: AuxiliaryWorkerConfig[] + virtualModules: Map +} + +function virtualServiceWorkerId(workerName: string): string { + return `${VIRTUAL_SERVICE_WORKER_PREFIX}${encodeURIComponent(workerName)}` +} + +export function resolvedVirtualServiceWorkerId(workerName: string): string { + return `${RESOLVED_VIRTUAL_SERVICE_WORKER_PREFIX}${encodeURIComponent(workerName)}` +} + +function objectEntries(record: Record | undefined): Array<[string, T]> { + return record ? Object.entries(record) : [] +} + +function toWranglerDurableObjectBinding( + name: string, + value: NonNullable[string] +): { name: string; class_name: string; script_name?: string } { + if (typeof value === 'string') { + return { name, class_name: value } + } + + return { + name, + class_name: value.className, + script_name: value.scriptName + } +} + +function toWranglerWorkerConfig(worker: ResolvedWorker): WranglerConfig { + const queueProducers = worker.queueProducers + ? objectEntries(worker.queueProducers).map(([binding, producer]) => ({ + binding, + queue: producer.queueName + })) + : undefined + const queueConsumers = worker.queueConsumers + ? objectEntries(worker.queueConsumers).map(([queue, consumer]) => ({ + queue, + ...(consumer.maxBatchSize !== undefined && { max_batch_size: consumer.maxBatchSize as number }), + ...(consumer.maxBatchTimeout !== undefined && { max_batch_timeout: consumer.maxBatchTimeout as number }), + ...(consumer.maxRetries !== undefined && { max_retries: consumer.maxRetries as number }), + ...(typeof consumer.deadLetterQueue === 'string' && { dead_letter_queue: consumer.deadLetterQueue }), + ...(consumer.maxConcurrency !== undefined && { max_concurrency: consumer.maxConcurrency as number }), + ...(consumer.retryDelay !== undefined && { retry_delay: consumer.retryDelay as number }) + })) + : undefined + const queues = queueProducers || queueConsumers + ? { + ...(queueProducers && { producers: queueProducers }), + ...(queueConsumers && { consumers: queueConsumers }) + } + : undefined + const durableObjectBindings = worker.durableObjects + ? objectEntries(worker.durableObjects).map(([name, value]) => + toWranglerDurableObjectBinding(name, value) + ) + : [] + const serviceBindings = worker.serviceBindings + ? objectEntries(worker.serviceBindings).map(([binding, target]) => ({ + binding, + service: target.name, + ...(target.entrypoint && { entrypoint: target.entrypoint }) + })) + : [] + + const config: WranglerConfig = { + name: worker.name, + main: virtualServiceWorkerId(worker.name), + compatibility_date: worker.compatibilityDate, + ...(worker.compatibilityFlags && { compatibility_flags: worker.compatibilityFlags }), + ...(worker.bindings && { vars: worker.bindings }), + ...(worker.kvNamespaces && { + kv_namespaces: objectEntries(worker.kvNamespaces).map(([binding, id]) => ({ + binding, + id + })) + }), + ...(worker.r2Buckets && { + r2_buckets: objectEntries(worker.r2Buckets).map(([binding, bucket_name]) => ({ + binding, + bucket_name + })) + }), + ...(worker.d1Databases && { + d1_databases: objectEntries(worker.d1Databases).map(([binding, database_id]) => ({ + binding, + database_id, + database_name: database_id + })) + }), + ...(durableObjectBindings.length > 0 && { + durable_objects: { + bindings: durableObjectBindings + } + }), + ...(serviceBindings.length > 0 && { + services: serviceBindings + }), + ...(queues && { queues }) + } + + return config +} + +export function createAuxiliaryServiceWorkerConfigs( + resolution: ServiceBindingResolution | null | undefined +): AuxiliaryServiceWorkerResult { + if (!resolution || resolution.workers.length === 0) { + return { auxiliaryWorkers: [], virtualModules: new Map() } + } + + const virtualModules = new Map() + const auxiliaryWorkers = resolution.workers.map((worker) => { + virtualModules.set(resolvedVirtualServiceWorkerId(worker.name), worker.script) + + return { + config: toWranglerWorkerConfig(worker) as unknown as Record + } + }) + + return { auxiliaryWorkers, virtualModules } +} diff --git a/packages/devflare/src/vite/plugin-transform.ts b/packages/devflare/src/vite/plugin-transform.ts new file mode 100644 index 0000000..14bc922 --- /dev/null +++ b/packages/devflare/src/vite/plugin-transform.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Devflare Vite Plugin โ€” Transform hook +// ============================================================================= +// Pure helper extracted from devflarePlugin().transform(). Decides which +// devflare-side source-level rewrites apply to a given module: worker +// entrypoint instrumentation and (optional) Durable Object class transforms. +// ============================================================================= + +interface TransformResult { + code: string + map?: any +} + +export interface RunDevflareTransformOptions { + doTransforms: boolean +} + +const TRANSFORMABLE_EXTENSIONS = ['.ts', '.tsx', '.js'] as const + +/** + * Returns `true` when devflare may rewrite the given module. Skips + * `node_modules` and any file that isn't a `.ts`/`.tsx`/`.js`. + */ +export function isTransformCandidate(id: string): boolean { + if (id.includes('node_modules')) return false + return TRANSFORMABLE_EXTENSIONS.some((ext) => id.endsWith(ext)) +} + +/** + * Worker-entrypoint instrumentation step. + * + * Only handles `worker.ts` / `worker.js` files and only when + * `shouldTransformWorker` accepts them. Returns `null` when this step does + * not apply, so the caller can fall through to other transforms. + */ +export async function runWorkerEntryTransform( + code: string, + id: string +): Promise { + if (!id.endsWith('worker.ts') && !id.endsWith('worker.js')) { + return null + } + + const { + shouldTransformWorker, + transformWorkerEntrypoint + } = await import('../transform/worker-entrypoint') + + if (!shouldTransformWorker(code, id)) { + return null + } + + const result = transformWorkerEntrypoint(code, id) + if (!result) return null + + return { + code: result.code, + map: result.map + } +} + +/** + * Durable Object class transform step. + * + * Only runs when the user opted in via `doTransforms` and the source mentions + * `DurableObject` or the `@durableObject` decorator. Returns `null` when this + * step does not apply. + */ +export async function runDurableObjectTransform( + code: string, + id: string, + options: RunDevflareTransformOptions +): Promise { + if (!options.doTransforms) return null + if (!code.includes('DurableObject') && !code.includes('@durableObject')) { + return null + } + + const { transformDurableObject } = await import('../transform/durable-object') + return transformDurableObject(code, id) +} + +/** + * Apply devflare's source-level transforms to a single module. + * + * Order: + * 1. Worker-entrypoint instrumentation (`runWorkerEntryTransform`) + * 2. Durable Object class transform (`runDurableObjectTransform`) + * + * Returns `null` when no transform applies, mirroring Vite's transform-hook + * contract. + */ +export async function runDevflareTransform( + code: string, + id: string, + options: RunDevflareTransformOptions +): Promise { + if (!isTransformCandidate(id)) return null + + const workerResult = await runWorkerEntryTransform(code, id) + if (workerResult) return workerResult + + return await runDurableObjectTransform(code, id, options) +} diff --git a/packages/devflare/src/vite/plugin.ts b/packages/devflare/src/vite/plugin.ts new file mode 100644 index 0000000..a56331b --- /dev/null +++ b/packages/devflare/src/vite/plugin.ts @@ -0,0 +1,359 @@ +// ============================================================================= +// Devflare Vite Plugin +// ============================================================================= +// Integrates with @cloudflare/vite-plugin to provide: +// - Config compilation (devflare.config.ts โ†’ generated wrangler.jsonc) +// - Durable Object transforms +// - Virtual DO entry module for auxiliaryWorkers +// - Development-time generated config in .devflare/ +// +// Architecture: +// - Main worker: SvelteKit/main app +// - Auxiliary worker: Auto-generated DO worker from files.durableObjects pattern +// - auxiliaryWorkers config passed to @cloudflare/vite-plugin +// ============================================================================= + +import { dirname, resolve } from 'pathe' +import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' +import { loadConfig } from '../config/loader' +import type { DevflareConfig } from '../config/schema' +import type { WranglerConfig } from '../config/compiler' +import { + generateVirtualDOEntry, + logDiscoveredDurableObjects, + RESOLVED_VIRTUAL_DO_ENTRY, + VIRTUAL_DO_ENTRY, + type AuxiliaryWorkerConfig, + type DODiscoveryResult +} from './plugin-durable-objects' +import { + RESOLVED_VIRTUAL_SERVICE_WORKER_PREFIX, + VIRTUAL_SERVICE_WORKER_PREFIX +} from './plugin-service-bindings' +import { + buildPluginContextState, + resolvePluginConfigPath, + writeGeneratedWranglerConfig +} from './plugin-context' +import { + buildPluginConfigHookResult +} from './plugin-config-hook' +import { runDevflareTransform } from './plugin-transform' + +export type { AuxiliaryWorkerConfig, DODiscoveryResult } + +// Config directory name (same as dev.ts) +const CONFIG_DIR = '.devflare' + +export interface DevflarePluginOptions { + /** + * Path to devflare.config.ts + * @default 'devflare.config.ts' + */ + configPath?: string + + /** + * Environment to use from config + */ + environment?: string + + /** + * Enable Durable Object transforms + * @default true + */ + doTransforms?: boolean + + /** + * Watch config file for changes in dev mode + * @default true + */ + watchConfig?: boolean + + /** + * Miniflare bridge port for WebSocket proxying + * If set, WebSocket requests will be proxied to Miniflare + * @default process.env.DEVFLARE_BRIDGE_PORT + */ + bridgePort?: number + + /** + * Additional patterns to proxy to Miniflare (for WebSocket DO connections) + * These patterns will have WebSocket requests proxied to Miniflare. + * Note: Patterns from `wsRoutes` in devflare.config.ts are automatically included. + * @default [] + */ + wsProxyPatterns?: string[] +} + +export interface DevflarePluginContext { + /** + * The compiled wrangler config (for programmatic use) + */ + wranglerConfig: WranglerConfig | null + + /** + * Config ready for @cloudflare/vite-plugin's programmatic config option + */ + cloudflareConfig: Record | null + + /** + * Project root directory + */ + projectRoot: string + + /** + * Auxiliary worker config for DOs (if any) + * Pass to @cloudflare/vite-plugin's auxiliaryWorkers option + */ + auxiliaryWorkerConfig: AuxiliaryWorkerConfig | null + auxiliaryWorkerConfigs: AuxiliaryWorkerConfig[] + serviceWorkerVirtualModules: Map + + /** + * Discovered DO files and their classes + */ + durableObjects: DODiscoveryResult | null +} + +interface PluginInstanceState { + context: DevflarePluginContext + projectRoot: string + devflareConfig: DevflareConfig | null + resolvedPluginConfigPath: string | null +} + +function createPluginState(): PluginInstanceState { + return { + context: { + wranglerConfig: null, + cloudflareConfig: null, + projectRoot: process.cwd(), + auxiliaryWorkerConfig: null, + auxiliaryWorkerConfigs: [], + serviceWorkerVirtualModules: new Map(), + durableObjects: null + }, + projectRoot: process.cwd(), + devflareConfig: null, + resolvedPluginConfigPath: null + } +} + +/** + * Reload `devflare.config.ts`, rebuild the plugin context state, mutate the + * shared plugin state in-place, and write the generated wrangler config to + * disk. Used by both `configResolved` (initial load) and the `configureServer` + * watcher (HMR reload). + */ +async function loadAndApplyConfig( + state: PluginInstanceState, + options: { configPath: string | undefined; environment: string | undefined }, + mode: 'serve' | 'build', + onContextUpdated: (ctx: DevflarePluginContext) => void +): Promise { + state.devflareConfig = await loadConfig({ + cwd: state.projectRoot, + configFile: options.configPath + }) + + const pluginState = await buildPluginContextState( + state.projectRoot, + state.devflareConfig, + options.environment, + mode, + state.resolvedPluginConfigPath ? dirname(state.resolvedPluginConfigPath) : state.projectRoot + ) + Object.assign(state.context, { + projectRoot: state.projectRoot, + ...pluginState + }) + onContextUpdated(state.context) + + logDiscoveredDurableObjects(state.projectRoot, pluginState.durableObjects) + await writeGeneratedWranglerConfig(state.projectRoot, pluginState.wranglerConfig) +} + +// Module-level pointer to the most recently configured plugin instance. +// This is intentionally process-wide so that `getPluginContext()` โ€” a +// convenience API typically called from a single `vite.config.ts` โ€” can +// return the active plugin's context without requiring callers to hold a +// reference. Per-instance hooks do NOT read this; they close over their +// own state, so multiple `devflarePlugin()` calls in one process remain +// isolated from each other. +let lastPluginContext: DevflarePluginContext = createPluginState().context + +/** + * Get the compiled config context + * Can be used by other plugins or CLI commands + */ +export function getPluginContext(): DevflarePluginContext { + return lastPluginContext +} + +/** + * Devflare Vite Plugin + * + * @example + * ```ts + * // vite.config.ts + * import { defineConfig } from 'vite' + * import { sveltekit } from '@sveltejs/kit/vite' + * import { devflarePlugin, getPluginContext } from 'devflare/vite' + * import { cloudflare } from '@cloudflare/vite-plugin' + * + * export default defineConfig(async () => { + * // First, run devflare to get context + * const lfPlugin = devflarePlugin() + * + * return { + * plugins: [ + * lfPlugin, + * sveltekit(), + * // Access context after configResolved + * cloudflare({ + * config: getPluginContext().cloudflareConfig, + * auxiliaryWorkers: getPluginContext().auxiliaryWorkerConfigs.length > 0 + * ? getPluginContext().auxiliaryWorkerConfigs + * : undefined + * }) + * ] + * } + * }) + * ``` + */ +export function devflarePlugin(options: DevflarePluginOptions = {}): Plugin { + const { + configPath, + environment, + doTransforms = true, + watchConfig = true, + bridgePort = process.env.DEVFLARE_BRIDGE_PORT ? parseInt(process.env.DEVFLARE_BRIDGE_PORT, 10) : undefined, + wsProxyPatterns = [] + } = options + + const state = createPluginState() + + return { + name: 'devflare', + + // Run before other plugins + enforce: 'pre', + + // Configure WebSocket proxy for DO connections in dev mode + // Also inject build-time constants (workerName) + async config(config, { command }) { + const cwd = config.root ?? process.cwd() + return buildPluginConfigHookResult( + cwd, + { configPath, bridgePort, wsProxyPatterns }, + command as 'serve' | 'build', + (config.define ?? {}) as Record + ) + }, + + // Handle virtual module resolution + resolveId(id: string) { + if (id === VIRTUAL_DO_ENTRY) { + return RESOLVED_VIRTUAL_DO_ENTRY + } + if (id.startsWith(VIRTUAL_SERVICE_WORKER_PREFIX)) { + return '\0' + id + } + return null + }, + + // Load virtual module content + async load(id: string) { + if (id === RESOLVED_VIRTUAL_DO_ENTRY) { + if (!state.context.durableObjects) { + return '// No Durable Objects configured\nexport default { fetch: () => new Response("No DOs") }' + } + return generateVirtualDOEntry(state.context.durableObjects) + } + if (id.startsWith(RESOLVED_VIRTUAL_SERVICE_WORKER_PREFIX)) { + return state.context.serviceWorkerVirtualModules.get(id) ?? null + } + return null + }, + + async configResolved(config: ResolvedConfig) { + state.projectRoot = config.root + state.context.projectRoot = state.projectRoot + state.resolvedPluginConfigPath = await resolvePluginConfigPath(state.projectRoot, configPath) + + try { + await loadAndApplyConfig( + state, + { configPath, environment }, + config.command === 'build' ? 'build' : 'serve', + (ctx) => { lastPluginContext = ctx } + ) + + if (config.command === 'serve') { + console.log('[devflare] Config generated to .devflare/wrangler.jsonc') + if (state.context.auxiliaryWorkerConfig) { + console.log('[devflare] โœ“ Auxiliary DO worker configured') + } + } + + if (config.command === 'build') { + console.log(`[devflare] Generated ${CONFIG_DIR}/wrangler.jsonc`) + } + } catch (error) { + if (error instanceof Error) { + console.error('[devflare] Config error:', error.message) + } + throw error + } + }, + + configureServer(server: ViteDevServer) { + if (!watchConfig) return + + // Watch devflare.config.ts for changes + const fullConfigPath = state.resolvedPluginConfigPath + ?? resolve(state.projectRoot, configPath || 'devflare.config.ts') + + server.watcher.add(fullConfigPath) + + server.watcher.on('change', async (changedPath: string) => { + if (changedPath === fullConfigPath) { + console.log('[devflare] Config changed, reloading...') + + try { + await loadAndApplyConfig( + state, + { configPath, environment }, + 'serve', + (ctx) => { lastPluginContext = ctx } + ) + + console.log('[devflare] Config reloaded') + + // Trigger HMR + server.ws.send({ + type: 'full-reload', + path: '*' + }) + } catch (error) { + console.error('[devflare] Failed to reload config:', error) + } + } + }) + }, + + // Transform Durable Object classes and Worker Entrypoints + async transform(code: string, id: string) { + return runDevflareTransform(code, id, { doTransforms }) + } + } +} + +/** + * Get cloudflare config for programmatic use with @cloudflare/vite-plugin. + * Re-exported from `./plugin-programmatic`. + */ +export { getCloudflareConfig, getDevflareConfigs } from './plugin-programmatic' + +// Default export for convenience +export default devflarePlugin diff --git a/packages/devflare/src/worker-entry/composed-worker.ts b/packages/devflare/src/worker-entry/composed-worker.ts new file mode 100644 index 0000000..c797677 --- /dev/null +++ b/packages/devflare/src/worker-entry/composed-worker.ts @@ -0,0 +1,568 @@ +import { dirname, relative, resolve } from 'pathe' +import type { DevflareConfig } from '../config' +import { normalizeDOBinding } from '../config/schema' +import { resolveConfigForEnvironment } from '../config/resolve' +import { DEFAULT_DO_PATTERN } from '../utils/glob' +import { discoverDurableObjectFiles } from './durable-object-discovery' +import { discoverRoutes, type RouteDiscoveryResult } from './routes' +import { + looksLikeBuildArtifactPath, + resolveWorkerSurfacePaths, + type WorkerSurfacePaths +} from './surface-paths' + +interface GeneratedRouteModuleImport { + identifier: string + importPath: string + filePath: string + routePath: string + segmentsJson: string +} + +interface GeneratedDurableObjectExport { + importPath: string + filePath: string + classNames: string[] +} + +export interface PrepareComposedWorkerEntrypointOptions { + devInternalEmail?: boolean + includeDevOnlyHooks?: boolean +} + +/** + * Minimal structured codegen helper used to emit the composed worker module. + * Each method appends a discrete "section" to the output; sections are joined + * by a single newline when rendered, which matches the shape previously + * produced by the hand-written template literal. + */ +class CodeBuilder { + private readonly sections: string[] = [] + + importStatement(specifiers: readonly string[], from: string): this { + this.sections.push(`import { ${specifiers.join(', ')} } from '${from}'`) + return this + } + + importNamespace(identifier: string, from: string): this { + this.sections.push(`import * as ${identifier} from '${from}'`) + return this + } + + reExport(names: readonly string[], from: string): this { + this.sections.push(`export { ${names.join(', ')} } from '${from}'`) + return this + } + + constDeclaration(name: string, value: string): this { + this.sections.push(`const ${name} = ${value}`) + return this + } + + classDeclaration(body: string): this { + this.sections.push(body) + return this + } + + exportDefault(body: string): this { + this.sections.push(`export default ${body}`) + return this + } + + raw(text: string): this { + this.sections.push(text) + return this + } + + blank(): this { + this.sections.push('') + return this + } + + toString(): string { + return this.sections.join('\n') + } +} + +const DEV_ONLY_EMAIL_HOOKS_SOURCE = ` +function __devflareCreateEmailHeaders(rawBody) { + const headers = new Headers() + const lines = rawBody.split(/\\r?\\n/) + + for (const line of lines) { + if (line.trim() === '') { + break + } + + const colonIndex = line.indexOf(':') + if (colonIndex <= 0) { + continue + } + + headers.append(line.slice(0, colonIndex).trim(), line.slice(colonIndex + 1).trim()) + } + + return headers +} + +function __devflareCreateEmailRawStream(rawBody) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(rawBody)) + controller.close() + } + }) +} + +async function __devflareHandleInternalEmail(request, env, ctx) { + if (!__devflareEmailHandler) { + return new Response('Email handler not configured', { status: 501 }) + } + + const from = request.headers.get('x-devflare-email-from') || 'unknown@example.com' + const to = request.headers.get('x-devflare-email-to') || 'worker@example.com' + const rawBody = await request.text() + const emailMessage = { + from, + to, + headers: __devflareCreateEmailHeaders(rawBody), + raw: __devflareCreateEmailRawStream(rawBody), + rawSize: rawBody.length, + setReject(reason) { + console.warn('[Devflare email rejected]', reason) + }, + async forward(rcptTo) { + console.log('[Devflare email forwarded]', rcptTo) + return Promise.resolve() + }, + async reply(message) { + console.log('[Devflare email reply sent]', message?.from) + return Promise.resolve() + } + } + + const __devflareEvent = createEmailEvent(emailMessage, env, ctx) + + await runWithEventContext( + __devflareEvent, + () => __devflareEmailHandler(__devflareEvent, env, ctx) + ) + + return new Response(JSON.stringify({ ok: true, from, to }), { + headers: { 'Content-Type': 'application/json' } + }) +} +` + +function emitDevOnlyEmailHooks(builder: CodeBuilder, options: { enabled: boolean }): void { + if (!options.enabled) { + return + } + + builder.raw(DEV_ONLY_EMAIL_HOOKS_SOURCE) +} + +const RESOLVE_HANDLER_DECLARATION = `const __devflareResolveHandler = (module, namedExport) => { + const defaultExport = module.default + + if (typeof defaultExport === 'function') { + return defaultExport + } + + if (defaultExport && typeof defaultExport[namedExport] === 'function') { + return defaultExport[namedExport].bind(defaultExport) + } + + if (typeof module[namedExport] === 'function') { + return module[namedExport] + } + + return null +}` + +function buildDefaultExportBody(options: { + hasFetchDispatch: boolean + includeDevOnlyHooks: boolean +}): string { + const devOnlyEmailEntry = options.includeDevOnlyHooks + ? `const url = new URL(request.url) + + if ( + request.headers.get('x-devflare-event') === 'email' + && url.pathname === '/_devflare/internal/email' + ) { + return __devflareHandleInternalEmail(request, env, ctx) + } + + ` + : '' + + return `{ + ...(${options.hasFetchDispatch ? 'true' : 'false'} + ? { + async fetch(request, env, ctx) { + ${devOnlyEmailEntry}const __devflareInitialRouteMatch = __devflareHasRoutes ? matchFetchRoute(__devflareRoutes, request) : null + const __devflareEvent = createFetchEvent(request, env, ctx, { + params: __devflareInitialRouteMatch?.params ?? {} + }) + return runWithEventContext( + __devflareEvent, + () => invokeFetchModule( + __devflareFetchModule, + __devflareEvent, + __devflareHasRoutes + ? createRouteResolve(__devflareRoutes, __devflareEvent) + : undefined + ) + ) + } + } + : {}), + ...(__devflareQueueHandler + ? { + async queue(batch, env, ctx) { + assertExplicitQueueHandlerStyle(__devflareQueueHandler) + const __devflareEvent = createQueueEvent(batch, env, ctx) + return runWithEventContext( + __devflareEvent, + () => __devflareQueueHandler(__devflareEvent, env, ctx) + ) + } + } + : {}), + ...(__devflareScheduledHandler + ? { + async scheduled(controller, env, ctx) { + assertExplicitScheduledHandlerStyle(__devflareScheduledHandler) + const __devflareEvent = createScheduledEvent(controller, env, ctx) + return runWithEventContext( + __devflareEvent, + () => __devflareScheduledHandler(__devflareEvent, env, ctx) + ) + } + } + : {}), + ...(__devflareEmailHandler + ? { + async email(message, env, ctx) { + const __devflareEvent = createEmailEvent(message, env, ctx) + return runWithEventContext( + __devflareEvent, + () => __devflareEmailHandler(__devflareEvent, env, ctx) + ) + } + } + : {}), + ...(__devflareTailHandler + ? { + async tail(events, env, ctx) { + const __devflareEvent = createTailEvent(events, env, ctx) + return runWithEventContext( + __devflareEvent, + () => __devflareTailHandler.length >= 2 + ? __devflareTailHandler(events, env, ctx) + : __devflareTailHandler(__devflareEvent, env, ctx) + ) + } + } + : {}) +}` +} + +function getComposedWorkerEntrypointSource( + surfaceImportPaths: WorkerSurfacePaths, + configuredLocalSendEmailBindings: Record = {}, + durableObjectExports: readonly GeneratedDurableObjectExport[] = [], + routeImports: readonly GeneratedRouteModuleImport[] = [], + options: PrepareComposedWorkerEntrypointOptions = {} +): string { + const includeDevOnlyHooks = options.includeDevOnlyHooks ?? options.devInternalEmail === true + + const importsBuilder = new CodeBuilder() + importsBuilder.importStatement( + [ + 'assertExplicitQueueHandlerStyle', + 'assertExplicitScheduledHandlerStyle', + 'createEmailEvent', + 'createFetchEvent', + 'createQueueEvent', + 'createRouteResolve', + 'createScheduledEvent', + 'createTailEvent', + 'invokeFetchModule', + 'matchFetchRoute', + 'runWithEventContext', + 'setLocalSendEmailBindings' + ], + 'devflare/runtime' + ) + + const fallbackModules: Array<{ identifier: string, importPath: string | null }> = [ + { identifier: '__devflareFetchModule', importPath: surfaceImportPaths.fetch }, + { identifier: '__devflareQueueModule', importPath: surfaceImportPaths.queue }, + { identifier: '__devflareScheduledModule', importPath: surfaceImportPaths.scheduled }, + { identifier: '__devflareEmailModule', importPath: surfaceImportPaths.email }, + { identifier: '__devflareTailModule', importPath: surfaceImportPaths.tail } + ] + + const fallbacksBuilder = new CodeBuilder() + for (const { identifier, importPath } of fallbackModules) { + if (importPath) { + importsBuilder.importNamespace(identifier, importPath) + } else { + fallbacksBuilder.constDeclaration(identifier, '{}') + } + } + + for (const routeImport of routeImports) { + importsBuilder.importNamespace(routeImport.identifier, routeImport.importPath) + } + + const reExportsBuilder = new CodeBuilder() + for (const { classNames, importPath } of durableObjectExports) { + reExportsBuilder.reExport(classNames, importPath) + } + + const routeManifestEntries = routeImports.map(({ identifier, filePath, routePath, segmentsJson }) => { + return `\t{ filePath: ${JSON.stringify(filePath)}, routePath: ${JSON.stringify(routePath)}, segments: ${segmentsJson}, module: ${identifier} }` + }) + + const builder = new CodeBuilder() + builder.raw(importsBuilder.toString()) + builder.raw(fallbacksBuilder.toString()) + builder.raw(reExportsBuilder.toString()) + builder.blank() + builder.raw(`setLocalSendEmailBindings(${JSON.stringify(configuredLocalSendEmailBindings)})`) + builder.blank() + builder.constDeclaration('__devflareHasFetchModule', surfaceImportPaths.fetch ? 'true' : 'false') + builder.raw(`const __devflareRoutes = [\n${routeManifestEntries.join(',\n')}\n]`) + builder.constDeclaration('__devflareHasRoutes', '__devflareRoutes.length > 0') + builder.blank() + builder.raw(RESOLVE_HANDLER_DECLARATION) + builder.blank() + builder.constDeclaration('__devflareQueueHandler', "__devflareResolveHandler(__devflareQueueModule, 'queue')") + builder.constDeclaration('__devflareScheduledHandler', "__devflareResolveHandler(__devflareScheduledModule, 'scheduled')") + builder.constDeclaration('__devflareEmailHandler', "__devflareResolveHandler(__devflareEmailModule, 'email')") + builder.constDeclaration('__devflareTailHandler', "__devflareResolveHandler(__devflareTailModule, 'tail')") + emitDevOnlyEmailHooks(builder, { enabled: includeDevOnlyHooks }) + builder.blank() + builder.exportDefault(buildDefaultExportBody({ + hasFetchDispatch: Boolean(surfaceImportPaths.fetch) || routeImports.length > 0 || includeDevOnlyHooks, + includeDevOnlyHooks + })) + builder.raw('') + + return builder.toString() +} + +function toImportSpecifier(fromFilePath: string, toFilePath: string): string { + const specifier = relative(dirname(fromFilePath), toFilePath).replace(/\\/g, '/') + return specifier.startsWith('.') ? specifier : `./${specifier}` +} + +function createGeneratedRouteModuleImports( + entryPath: string, + routeDiscovery: RouteDiscoveryResult | null +): GeneratedRouteModuleImport[] { + if (!routeDiscovery) { + return [] + } + + return routeDiscovery.routes.map((route, index) => ({ + identifier: `__devflareRouteModule${index}`, + importPath: toImportSpecifier(entryPath, route.absolutePath), + filePath: route.filePath, + routePath: route.routePath, + segmentsJson: JSON.stringify(route.segments) + })) +} + +async function createGeneratedDurableObjectExports( + entryPath: string, + cwd: string, + config: DevflareConfig +): Promise { + if (config.files?.durableObjects === false || !config.bindings?.durableObjects) { + return [] + } + + const localClassNames = new Set( + Object.values(config.bindings.durableObjects) + .map((binding) => normalizeDOBinding(binding)) + .filter((binding) => binding.kind === 'local') + .map((binding) => binding.className) + ) + + if (localClassNames.size === 0) { + return [] + } + + const pattern = typeof config.files?.durableObjects === 'string' + ? config.files.durableObjects + : DEFAULT_DO_PATTERN + const discoveredFiles = await discoverDurableObjectFiles(cwd, pattern) + const exports: GeneratedDurableObjectExport[] = [] + const discoveredClassNames = new Set() + + for (const [filePath, allClassNames] of discoveredFiles) { + const classNames = allClassNames.filter((className) => localClassNames.has(className)) + + if (classNames.length === 0) { + continue + } + + for (const className of classNames) { + discoveredClassNames.add(className) + } + + exports.push({ + importPath: toImportSpecifier(entryPath, filePath), + filePath, + classNames + }) + } + + const missingClassNames = Array.from(localClassNames).filter((className) => !discoveredClassNames.has(className)) + + if (missingClassNames.length > 0) { + throw new Error( + `Failed to discover local Durable Object class${missingClassNames.length === 1 ? '' : 'es'} ${missingClassNames.join(', ')} for worker composition. ` + + `Ensure files.durableObjects matches the source file pattern for your do.* files.` + ) + } + + return exports +} + +/** + * Returns true when the resolved config has any composition signal other than + * `files.fetch` (queue / scheduled / email handler files, durable-object + * bindings, sendEmail bindings, or routes). Used to decide whether a missing + * build-artifact fetch path can be safely deferred to wrangler/vite. + */ +function mayRequireCompositionBesidesFetch(config: DevflareConfig): boolean { + const files = config.files ?? {} + if (typeof files.queue === 'string' && files.queue) return true + if (typeof files.scheduled === 'string' && files.scheduled) return true + if (typeof files.email === 'string' && files.email) return true + if (typeof files.tail === 'string' && files.tail) return true + if (files.durableObjects) return true + if (files.routes) return true + const bindings = config.bindings ?? {} + const doBindings = bindings.durableObjects + if (doBindings && Object.keys(doBindings).length > 0) return true + const sendEmail = bindings.sendEmail + if (sendEmail && Object.keys(sendEmail).length > 0) return true + return false +} + +function needsComposedWorkerEntrypoint( + cwd: string, + surfacePaths: WorkerSurfacePaths, + config: DevflareConfig, + routeDiscovery: RouteDiscoveryResult | null +): boolean { + const hasAdditionalWorkerSurfaces = Boolean( + surfacePaths.queue + || surfacePaths.scheduled + || surfacePaths.email + || surfacePaths.tail + || routeDiscovery?.routes.length + ) + + if (hasAdditionalWorkerSurfaces) { + return true + } + + if (!surfacePaths.fetch) { + return false + } + + const assetsDirectory = config.assets?.directory + if (assetsDirectory) { + const generatedAssetsWorkerPath = resolve(cwd, assetsDirectory, '_worker.js') + if (surfacePaths.fetch === generatedAssetsWorkerPath) { + return false + } + } + + return Boolean( + surfacePaths.fetch + ) +} + +export async function prepareComposedWorkerEntrypoint( + cwd: string, + config: DevflareConfig, + environment?: string, + options: PrepareComposedWorkerEntrypointOptions = {} +): Promise { + const resolvedConfig = resolveConfigForEnvironment(config, environment) + if ( + resolvedConfig.wrangler?.passthrough + && Object.prototype.hasOwnProperty.call(resolvedConfig.wrangler.passthrough, 'main') + ) { + return null + } + + // Build-artifact deferral: if files.fetch points at a framework build output + // (e.g. `.svelte-kit/cloudflare/_worker.js`), the file does not exist yet at + // this stage. When no other surface requires composition, skip composition + // entirely so wrangler/vite picks up the build output post-build. When other + // surfaces ARE present, fall through to resolveWorkerSurfacePaths so the + // user gets a clear error explaining that composition cannot wrap an + // unbuilt artifact. + const configuredFetch = resolvedConfig.files?.fetch + if (typeof configuredFetch === 'string' && looksLikeBuildArtifactPath(configuredFetch)) { + const fs = await import('node:fs/promises') + const fetchAbsolute = resolve(cwd, configuredFetch) + let fetchExists = true + try { + await fs.access(fetchAbsolute) + } catch { + fetchExists = false + } + if (!fetchExists && !mayRequireCompositionBesidesFetch(resolvedConfig)) { + return null + } + } + + const surfacePaths = await resolveWorkerSurfacePaths(cwd, resolvedConfig) + const routeDiscovery = await discoverRoutes(cwd, resolvedConfig) + if (!needsComposedWorkerEntrypoint(cwd, surfacePaths, resolvedConfig, routeDiscovery)) { + return null + } + + const fs = await import('node:fs/promises') + const entryDir = resolve(cwd, '.devflare', 'worker-entrypoints') + const entryPath = resolve(entryDir, 'main.ts') + + await fs.mkdir(entryDir, { recursive: true }) + + const surfaceImportPaths: WorkerSurfacePaths = { + fetch: surfacePaths.fetch ? toImportSpecifier(entryPath, surfacePaths.fetch) : null, + queue: surfacePaths.queue ? toImportSpecifier(entryPath, surfacePaths.queue) : null, + scheduled: surfacePaths.scheduled ? toImportSpecifier(entryPath, surfacePaths.scheduled) : null, + email: surfacePaths.email ? toImportSpecifier(entryPath, surfacePaths.email) : null, + tail: surfacePaths.tail ? toImportSpecifier(entryPath, surfacePaths.tail) : null + } + const durableObjectExports = await createGeneratedDurableObjectExports(entryPath, cwd, resolvedConfig) + const routeImports = createGeneratedRouteModuleImports(entryPath, routeDiscovery) + + await fs.writeFile( + entryPath, + getComposedWorkerEntrypointSource( + surfaceImportPaths, + resolvedConfig.bindings?.sendEmail ?? {}, + durableObjectExports, + routeImports, + options + ) + ) + + return entryPath +} diff --git a/packages/devflare/src/worker-entry/durable-object-discovery.ts b/packages/devflare/src/worker-entry/durable-object-discovery.ts new file mode 100644 index 0000000..a4d1e3e --- /dev/null +++ b/packages/devflare/src/worker-entry/durable-object-discovery.ts @@ -0,0 +1,61 @@ +// ============================================================================= +// Shared Durable Object discovery helper +// ============================================================================= +// Single source of truth for "walk a glob, return file โ†’ DO class names". +// Consumers (vite plugin, dev-server DO bundler, composed-worker entrypoint +// generator, test fixtures) should use these helpers instead of re-rolling +// their own filesystem-walk + `findDurableObjectClasses` loop. +// ============================================================================= + +import { findDurableObjectClasses } from '../transform/durable-object' +import { findFiles } from '../utils/glob' + +export interface DODiscoveryResult { + /** Map of file path โ†’ array of DO class names found in that file */ + files: Map + /** Worker name for the auxiliary DO worker */ + workerName: string +} + +/** + * Walk the glob `pattern` under `cwd` and return a map of file path โ†’ + * Durable Object class names declared in that file. Files that fail to read + * or contain no DO classes are omitted. Respects `.gitignore` automatically + * (via `findFiles`). + */ +export async function discoverDurableObjectFiles( + cwd: string, + pattern: string +): Promise> { + const result = new Map() + const matchedFiles = await findFiles(pattern, { cwd }) + const fs = await import('node:fs/promises') + + for (const filePath of matchedFiles) { + try { + const code = await fs.readFile(filePath, 'utf-8') + const classNames = findDurableObjectClasses(code) + if (classNames.length > 0) { + result.set(filePath, classNames) + } + } catch (error) { + console.warn(`[devflare] Failed to read DO file: ${filePath}`, error) + } + } + + return result +} + +/** + * Vite-plugin-shaped wrapper around `discoverDurableObjectFiles`. Returns a + * `DODiscoveryResult` carrying the discovered file map plus the auxiliary + * worker name. + */ +export async function discoverDurableObjects( + projectRoot: string, + pattern: string, + workerName: string +): Promise { + const files = await discoverDurableObjectFiles(projectRoot, pattern) + return { files, workerName } +} diff --git a/packages/devflare/src/worker-entry/extensions.ts b/packages/devflare/src/worker-entry/extensions.ts new file mode 100644 index 0000000..22daea3 --- /dev/null +++ b/packages/devflare/src/worker-entry/extensions.ts @@ -0,0 +1,31 @@ +// ============================================================================= +// Shared worker source extension lists +// ============================================================================= +// Single source of truth for the file extensions Devflare treats as worker +// source modules. Used by: +// - transform/worker-entrypoint.ts (recognizing worker entry files + gating +// TS-only emit) +// - worker-entry/surface-paths.ts (default-handler file lookup for +// src/fetch, src/queue, src/scheduled, src/email) +// - worker-entry/routes.ts (file-route discovery globs) +// ============================================================================= + +/** Extensions accepted as worker source modules. */ +export const SUPPORTED_WORKER_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.mjs', + '.cts', + '.cjs', + '.js', + '.jsx' +] as const + +/** Subset of {@link SUPPORTED_WORKER_EXTENSIONS} that may host TypeScript-only syntax. */ +export const TS_WORKER_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts' +] as const diff --git a/packages/devflare/src/worker-entry/routes.ts b/packages/devflare/src/worker-entry/routes.ts new file mode 100644 index 0000000..f5dc1b8 --- /dev/null +++ b/packages/devflare/src/worker-entry/routes.ts @@ -0,0 +1,296 @@ +// ============================================================================= +// File Route Discovery +// ============================================================================= + +import { relative, resolve } from 'pathe' +import type { DevflareConfig } from '../config' +import type { RouteSegment } from '../runtime/router/types' +import { findFiles } from '../utils/glob' +import { SUPPORTED_WORKER_EXTENSIONS } from './extensions' + +export const DEFAULT_ROUTE_DIR = 'src/routes' + +const DEFAULT_ROUTE_FILE_PATTERNS = SUPPORTED_WORKER_EXTENSIONS.map( + (ext) => `**/*${ext}` +) + +export interface DiscoveredRoute { + readonly absolutePath: string + readonly filePath: string + readonly routePath: string + readonly segments: readonly RouteSegment[] +} + +export interface RouteDiscoveryResult { + readonly dir: string + readonly absoluteDir: string + readonly prefix: string + readonly routes: readonly DiscoveredRoute[] +} + +function normalizeRoutePrefix(prefix?: string): string { + if (!prefix || prefix === '/') { + return '' + } + + const normalized = prefix.startsWith('/') ? prefix : `/${prefix}` + return normalized.replace(/\/+$/g, '') +} + +function createStaticSegmentsFromPrefix(prefix: string): RouteSegment[] { + if (!prefix) { + return [] + } + + return prefix + .split('/') + .filter(Boolean) + .map((value) => ({ + type: 'static' as const, + value + })) +} + +function shouldIgnoreRouteFile(relativePath: string): boolean { + return relativePath + .split('/') + .some((segment) => segment.startsWith('_')) +} + +function toRoutePath(segments: readonly RouteSegment[]): string { + if (segments.length === 0) { + return '/' + } + + return `/${segments.map((segment) => { + if (segment.type === 'static') { + return segment.value + } + + if (segment.type === 'param') { + return `[${segment.name}]` + } + + if (segment.type === 'rest') { + return `[...${segment.name}]` + } + + return `[[...${segment.name}]]` + }).join('/')}` +} + +function getRouteSignature(segments: readonly RouteSegment[]): string { + if (segments.length === 0) { + return '/' + } + + return segments.map((segment) => { + if (segment.type === 'static') { + return `static:${segment.value}` + } + + if (segment.type === 'param') { + return 'param' + } + + if (segment.type === 'rest') { + return 'rest' + } + + return 'optional-rest' + }).join('/') +} + +function getSegmentPriority(segment: RouteSegment): number { + if (segment.type === 'static') { + return 4 + } + + if (segment.type === 'param') { + return 3 + } + + if (segment.type === 'rest') { + return 1 + } + + return 0 +} + +function compareRoutes(a: DiscoveredRoute, b: DiscoveredRoute): number { + const maxLength = Math.max(a.segments.length, b.segments.length) + + for (let index = 0; index < maxLength; index += 1) { + const left = a.segments[index] + const right = b.segments[index] + + if (!left && !right) { + break + } + + if (!left) { + return 1 + } + + if (!right) { + return -1 + } + + const priorityDifference = getSegmentPriority(right) - getSegmentPriority(left) + if (priorityDifference !== 0) { + return priorityDifference + } + + if (left.type === 'static' && right.type === 'static') { + const lexicalDifference = left.value.localeCompare(right.value) + if (lexicalDifference !== 0) { + return lexicalDifference + } + } + } + + return a.filePath.localeCompare(b.filePath) +} + +function parseRouteSegments(relativePath: string, prefixSegments: readonly RouteSegment[]): RouteSegment[] { + const withoutExtension = relativePath.replace(/\.[^.]+$/u, '') + const rawSegments = withoutExtension.split('/').filter(Boolean) + const routeSegments: RouteSegment[] = [...prefixSegments] + + for (let index = 0; index < rawSegments.length; index += 1) { + const segment = rawSegments[index] + const isLastSegment = index === rawSegments.length - 1 + + if (segment === 'index' && isLastSegment) { + continue + } + + const optionalRestMatch = segment.match(/^\[\[\.\.\.(.+)\]\]$/u) + if (optionalRestMatch) { + if (!isLastSegment) { + throw new Error(`Optional rest segment must be the final segment: ${relativePath}`) + } + + routeSegments.push({ + type: 'optional-rest', + name: optionalRestMatch[1] + }) + continue + } + + const restMatch = segment.match(/^\[\.\.\.(.+)\]$/u) + if (restMatch) { + if (!isLastSegment) { + throw new Error(`Rest segment must be the final segment: ${relativePath}`) + } + + routeSegments.push({ + type: 'rest', + name: restMatch[1] + }) + continue + } + + const dynamicMatch = segment.match(/^\[(.+)\]$/u) + if (dynamicMatch) { + routeSegments.push({ + type: 'param', + name: dynamicMatch[1] + }) + continue + } + + routeSegments.push({ + type: 'static', + value: segment + }) + } + + return routeSegments +} + +async function directoryExists(dirPath: string): Promise { + const fs = await import('node:fs/promises') + + try { + const stat = await fs.stat(dirPath) + return stat.isDirectory() + } catch { + return false + } +} + +export function getRouteDirectoryCandidate(cwd: string, config: DevflareConfig): { dir: string; absoluteDir: string; prefix: string } | null { + const routesConfig = config.files?.routes + if (routesConfig === false) { + return null + } + + const dir = routesConfig?.dir ?? DEFAULT_ROUTE_DIR + return { + dir, + absoluteDir: resolve(cwd, dir), + prefix: normalizeRoutePrefix(routesConfig?.prefix) + } +} + +export async function discoverRoutes(cwd: string, config: DevflareConfig): Promise { + const routeDirectory = getRouteDirectoryCandidate(cwd, config) + if (!routeDirectory) { + return null + } + + if (!(await directoryExists(routeDirectory.absoluteDir))) { + return null + } + + const prefixSegments = createStaticSegmentsFromPrefix(routeDirectory.prefix) + const files = await findFiles(DEFAULT_ROUTE_FILE_PATTERNS, { + cwd: routeDirectory.absoluteDir, + absolute: true + }) + + const discoveredRoutes: DiscoveredRoute[] = [] + const routeSignatures = new Map() + + for (const absolutePath of files) { + const relativeToRouteDir = relative(routeDirectory.absoluteDir, absolutePath).replace(/\\/g, '/') + if (shouldIgnoreRouteFile(relativeToRouteDir)) { + continue + } + + const segments = parseRouteSegments(relativeToRouteDir, prefixSegments) + const routePath = toRoutePath(segments) + const filePath = relative(cwd, absolutePath).replace(/\\/g, '/') + const signature = getRouteSignature(segments) + const existingFilePath = routeSignatures.get(signature) + + if (existingFilePath) { + throw new Error( + `Conflicting file routes detected for "${routePath}". ` + + `Both "${existingFilePath}" and "${filePath}" resolve to the same route.` + ) + } + + routeSignatures.set(signature, filePath) + discoveredRoutes.push({ + absolutePath, + filePath, + routePath, + segments + }) + } + + if (discoveredRoutes.length === 0) { + return null + } + + discoveredRoutes.sort(compareRoutes) + + return { + dir: routeDirectory.dir, + absoluteDir: routeDirectory.absoluteDir, + prefix: routeDirectory.prefix, + routes: discoveredRoutes + } +} diff --git a/packages/devflare/src/worker-entry/surface-paths.ts b/packages/devflare/src/worker-entry/surface-paths.ts new file mode 100644 index 0000000..4b4fcab --- /dev/null +++ b/packages/devflare/src/worker-entry/surface-paths.ts @@ -0,0 +1,118 @@ +import { resolve } from 'pathe' +import type { DevflareConfig } from '../config' +import { SUPPORTED_WORKER_EXTENSIONS } from './extensions' + +const defaultEntriesFor = (surface: string): readonly string[] => + SUPPORTED_WORKER_EXTENSIONS.map((ext) => `src/${surface}${ext}`) + +export const DEFAULT_FETCH_ENTRY_FILES = defaultEntriesFor('fetch') +export const DEFAULT_QUEUE_ENTRY_FILES = defaultEntriesFor('queue') +export const DEFAULT_SCHEDULED_ENTRY_FILES = defaultEntriesFor('scheduled') +export const DEFAULT_EMAIL_ENTRY_FILES = defaultEntriesFor('email') +export const DEFAULT_TAIL_ENTRY_FILES = defaultEntriesFor('tail') + +/** + * Path prefixes that are known framework / bundler build outputs. + * A handler path that lives under one of these will not exist on disk + * until the framework's own build (e.g. `vite build`, `svelte-kit build`) + * has run, which happens AFTER devflare resolves surface paths. + */ +export const BUILD_OUTPUT_PATH_PREFIXES: readonly string[] = [ + '.svelte-kit/', + '.adapter-cloudflare/', + '.next/', + '.nuxt/', + '.output/', + '.vercel/', + 'dist/', + 'build/', + '.vinxi/', + '.solid/' +] + +/** Returns true when the path looks like a framework build output. */ +export function looksLikeBuildArtifactPath(configuredPath: string): boolean { + const normalised = configuredPath.replace(/\\/g, '/').replace(/^\.\//, '') + return BUILD_OUTPUT_PATH_PREFIXES.some((prefix) => normalised.startsWith(prefix)) +} + +export interface WorkerSurfacePaths { + fetch: string | null + queue: string | null + scheduled: string | null + email: string | null + tail: string | null +} + +export async function resolveWorkerHandlerPath( + cwd: string, + configuredPath: string | false | undefined, + defaultEntries: readonly string[], + surfaceName = 'worker' +): Promise { + if (configuredPath === false) { + return null + } + + const fs = await import('node:fs/promises') + + if (typeof configuredPath === 'string' && configuredPath) { + const absolutePath = resolve(cwd, configuredPath) + try { + await fs.access(absolutePath) + return absolutePath + } catch { + if (looksLikeBuildArtifactPath(configuredPath)) { + throw new Error( + `Configured ${surfaceName} handler "${configuredPath}" was not found.\n` + + `\n` + + `This path looks like a framework build output (e.g. SvelteKit / Vite / Next).\n` + + `Devflare resolves handler paths BEFORE your framework runs its build, so the file\n` + + `does not exist yet at this stage.\n` + + `\n` + + `Recommended fix โ€” point devflare at the build artifact via wrangler passthrough\n` + + `instead of files.${surfaceName}, so devflare skips composition and lets your\n` + + `framework write the worker entry that wrangler/vite then picks up:\n` + + `\n` + + ` files: { ${surfaceName}: false },\n` + + ` wrangler: {\n` + + ` passthrough: { main: '${configuredPath}' }\n` + + ` }\n` + + `\n` + + `Alternatively, run your framework build (e.g. \`vite build\`) before \`devflare build\`,\n` + + `or move the handler to a source file that exists at config time.` + ) + } + throw new Error(`Configured ${surfaceName} handler "${configuredPath}" was not found`) + } + } + + for (const defaultEntry of defaultEntries) { + const absolutePath = resolve(cwd, defaultEntry) + try { + await fs.access(absolutePath) + return absolutePath + } catch { + continue + } + } + + return null +} + +export async function resolveWorkerSurfacePaths( + cwd: string, + config: DevflareConfig +): Promise { + return { + fetch: await resolveWorkerHandlerPath(cwd, config.files?.fetch, DEFAULT_FETCH_ENTRY_FILES, 'fetch'), + queue: await resolveWorkerHandlerPath(cwd, config.files?.queue, DEFAULT_QUEUE_ENTRY_FILES, 'queue'), + scheduled: await resolveWorkerHandlerPath(cwd, config.files?.scheduled, DEFAULT_SCHEDULED_ENTRY_FILES, 'scheduled'), + email: await resolveWorkerHandlerPath(cwd, config.files?.email, DEFAULT_EMAIL_ENTRY_FILES, 'email'), + tail: await resolveWorkerHandlerPath(cwd, config.files?.tail, DEFAULT_TAIL_ENTRY_FILES, 'tail') + } +} + +export function hasWorkerSurfacePaths(surfacePaths: WorkerSurfacePaths): boolean { + return Object.values(surfacePaths).some((surfacePath) => typeof surfacePath === 'string' && surfacePath.length > 0) +} diff --git a/packages/devflare/src/workerName.ts b/packages/devflare/src/workerName.ts new file mode 100644 index 0000000..0b679d6 --- /dev/null +++ b/packages/devflare/src/workerName.ts @@ -0,0 +1,47 @@ +// ============================================================================= +// workerName โ€” Current worker's name (build-time injected) +// ============================================================================= +// This module exports the current worker's name as configured in devflare.config.ts +// The value is injected at build time by the devflare bundler. +// +// Usage: +// import { workerName } from 'devflare' +// console.log(`Running in worker: ${workerName}`) +// ============================================================================= + +// Declare the build-time injected global +declare const __DEVFLARE_WORKER_NAME__: string | undefined + +/** + * The current worker's name from devflare.config.ts + * + * This value is injected at build time by the devflare bundler. + * In development (non-bundled), it will be 'unknown' or throw an error. + * + * @example + * import { workerName } from 'devflare' + * + * export default { + * fetch(request) { + * return new Response(`Hello from ${workerName}`) + * } + * } + */ +export const workerName: string = (() => { + // This placeholder is replaced at build time by the bundler + // See: bundler/index.ts for the replacement logic + + // Check if we're in a bundled environment with injected value + // The bundler replaces this entire module with a simple export + if (typeof __DEVFLARE_WORKER_NAME__ !== 'undefined') { + return __DEVFLARE_WORKER_NAME__ + } + + // In dev mode or tests, try to read from environment + if (typeof process !== 'undefined' && process.env?.DEVFLARE_WORKER_NAME) { + return process.env.DEVFLARE_WORKER_NAME + } + + // Fallback for development/testing + return 'unknown' +})() diff --git a/packages/devflare/src/workflows/local-workflow-entrypoints.ts b/packages/devflare/src/workflows/local-workflow-entrypoints.ts new file mode 100644 index 0000000..1aafbfd --- /dev/null +++ b/packages/devflare/src/workflows/local-workflow-entrypoints.ts @@ -0,0 +1,145 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'pathe' +import type { ConsolaInstance } from 'consola' +import { bundleWorkerEntry } from '../bundler' +import { normalizeWorkflowBinding, type DevflareConfig } from '../config' +import { DEFAULT_WORKFLOW_PATTERN, findFiles } from '../utils/glob' + +interface LocalWorkflowEntrypoint { + bindingName: string + className: string + scriptPath: string +} + +export interface BundleWorkflowEntrypointScriptOptions { + logger?: ConsolaInstance +} + +function findExportedClasses(code: string): string[] { + const classes: string[] = [] + const classPattern = /export\s+class\s+(\w+)/g + + let match: RegExpExecArray | null + while ((match = classPattern.exec(code)) !== null) { + classes.push(match[1]) + } + + return classes +} + +function toImportSpecifier(fromDir: string, filePath: string): string { + const relativePath = relative(fromDir, filePath).replace(/\\/g, '/') + return relativePath.startsWith('.') ? relativePath : `./${relativePath}` +} + +async function discoverWorkflowClasses( + config: DevflareConfig, + configDir: string +): Promise> { + const classToFilePath = new Map() + const workflowPatternConfig = config.files?.workflows + const workflowPattern = typeof workflowPatternConfig === 'string' + ? workflowPatternConfig + : DEFAULT_WORKFLOW_PATTERN + + if (workflowPatternConfig === false) { + return classToFilePath + } + + const files = await findFiles(workflowPattern, { cwd: configDir }) + for (const filePath of files) { + try { + const code = await readFile(filePath, 'utf-8') + for (const className of findExportedClasses(code)) { + classToFilePath.set(className, filePath) + } + } catch { + // Discovery is best-effort; unresolved configured bindings fail below. + } + } + + return classToFilePath +} + +async function resolveLocalWorkflowEntrypoints( + config: DevflareConfig, + configDir: string +): Promise { + const workflows = config.bindings?.workflows + if (!workflows || Object.keys(workflows).length === 0) { + return [] + } + + const classToFilePath = await discoverWorkflowClasses(config, configDir) + const entrypoints: LocalWorkflowEntrypoint[] = [] + + for (const [bindingName, binding] of Object.entries(workflows)) { + const normalized = normalizeWorkflowBinding(binding) + if (normalized.scriptName) { + continue + } + + const scriptPath = classToFilePath.get(normalized.className) + if (!scriptPath) { + throw new Error( + `Workflow binding ${bindingName} (className: '${normalized.className}') not found.\n` + + `Either set files.workflows to match the workflow source file, or set scriptName when the workflow lives in another worker.` + ) + } + + entrypoints.push({ + bindingName, + className: normalized.className, + scriptPath + }) + } + + return entrypoints +} + +function buildWorkflowVirtualEntry(entrypoints: LocalWorkflowEntrypoint[], entryDir: string): string { + const imports = entrypoints.map((entrypoint, index) => { + const importName = `__DevflareWorkflow${index}` + const importPath = toImportSpecifier(entryDir, entrypoint.scriptPath) + return { + importName, + className: entrypoint.className, + line: `import { ${entrypoint.className} as ${importName} } from '${importPath}'` + } + }) + + const exports = imports.map((entrypoint) => { + return `export { ${entrypoint.importName} as ${entrypoint.className} }` + }) + + return [...imports.map((entrypoint) => entrypoint.line), '', ...exports].join('\n') +} + +export async function bundleWorkflowEntrypointScript( + config: DevflareConfig, + configDir: string, + options: BundleWorkflowEntrypointScriptOptions = {} +): Promise { + const entrypoints = await resolveLocalWorkflowEntrypoints(config, configDir) + if (entrypoints.length === 0) { + return '' + } + + const entryDir = resolve(configDir, '.devflare', 'workflow-entrypoints') + const entryPath = join(entryDir, '__entry.ts') + const outFile = join(entryDir, 'index.js') + await mkdir(entryDir, { recursive: true }) + await writeFile(entryPath, buildWorkflowVirtualEntry(entrypoints, entryDir)) + + await bundleWorkerEntry({ + cwd: configDir, + inputFile: entryPath, + outFile, + rolldownOptions: config.rolldown?.options, + sourcemap: config.rolldown?.sourcemap, + minify: config.rolldown?.minify, + logger: options.logger + }) + + return await readFile(outFile, 'utf-8') +} diff --git a/packages/devflare/tests/helpers/cloudflare-api.ts b/packages/devflare/tests/helpers/cloudflare-api.ts new file mode 100644 index 0000000..781e746 --- /dev/null +++ b/packages/devflare/tests/helpers/cloudflare-api.ts @@ -0,0 +1,32 @@ +export function jsonResponse(result: unknown, resultInfo?: Record): Response { + return new Response(JSON.stringify({ + success: true, + errors: [], + messages: [], + result, + ...(resultInfo ? { result_info: resultInfo } : {}) + }), { + headers: { + 'Content-Type': 'application/json' + } + }) +} + +export function createD1ResultsResponse(results: unknown[] = []): Response { + return jsonResponse([ + { + success: true, + meta: { + served_by: 'test', + duration: 0, + changes: 0, + last_row_id: 0, + changed_db: false, + size_after: 0, + rows_read: results.length, + rows_written: 0 + }, + results + } + ]) +} \ No newline at end of file diff --git a/packages/devflare/tests/helpers/mock-logger.ts b/packages/devflare/tests/helpers/mock-logger.ts new file mode 100644 index 0000000..7db6a82 --- /dev/null +++ b/packages/devflare/tests/helpers/mock-logger.ts @@ -0,0 +1,44 @@ +import { mock } from 'bun:test' + +export interface TestLogger { + info: ReturnType + warn: ReturnType + error: ReturnType + success: ReturnType + debug: ReturnType + log?: ReturnType + messages: Array<{ level: string; args: unknown[] }> +} + +export interface CreateLoggerOptions { + includeLog?: boolean +} + +const ANSI_REGEX = /\x1b\[[0-9;]*m/g + +export function stripAnsi(value: string): string { + return value.replace(ANSI_REGEX, '') +} + +export function renderMessages(logger: Pick): string[] { + return logger.messages.map((message) => stripAnsi(message.args.join(' '))) +} + +export function createLogger(options: CreateLoggerOptions = {}): TestLogger { + const includeLog = options.includeLog !== false + const messages: Array<{ level: string; args: unknown[] }> = [] + + const createMethod = (level: string) => mock((...args: unknown[]) => { + messages.push({ level, args }) + }) + + return { + info: createMethod('info'), + warn: createMethod('warn'), + error: createMethod('error'), + success: createMethod('success'), + debug: createMethod('debug'), + ...(includeLog ? { log: createMethod('log') } : {}), + messages + } +} \ No newline at end of file diff --git a/packages/devflare/tests/helpers/process-runner.ts b/packages/devflare/tests/helpers/process-runner.ts new file mode 100644 index 0000000..6d3a848 --- /dev/null +++ b/packages/devflare/tests/helpers/process-runner.ts @@ -0,0 +1,50 @@ +import * as fs from 'node:fs/promises' +import type { CliDependencies, ExecResult, ProcessRunner } from '../../src/cli/dependencies' + +export interface ExecInvocation { + command: string + args: string[] + options?: Record +} + +export function successResult(stdout: string = ''): ExecResult { + return { + exitCode: 0, + stdout, + stderr: '', + failed: false, + killed: false + } +} + +export function createProcessRunner( + handler: (command: string, args: string[], options?: Record) => Promise | ExecResult, + executions: ExecInvocation[], + options: { + spawnErrorMessage?: string + } = {} +): ProcessRunner { + const spawnErrorMessage = options.spawnErrorMessage ?? 'spawn() not implemented for this test' + + return { + async exec(command, args = [], execOptions = {}) { + const normalizedOptions = execOptions as Record + executions.push({ + command, + args, + options: normalizedOptions + }) + return await handler(command, args, normalizedOptions) + }, + spawn() { + throw new Error(spawnErrorMessage) + } + } +} + +export function createCliDependencies(exec: CliDependencies['exec']): CliDependencies { + return { + fs: fs as CliDependencies['fs'], + exec + } +} diff --git a/packages/devflare/tests/helpers/tracked-temp-directories.ts b/packages/devflare/tests/helpers/tracked-temp-directories.ts new file mode 100644 index 0000000..1b1cf19 --- /dev/null +++ b/packages/devflare/tests/helpers/tracked-temp-directories.ts @@ -0,0 +1,31 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export interface TrackedTempDirectories { + create(prefix: string): string + track(directory: string): string + cleanup(): void +} + +export function createTrackedTempDirectories(): TrackedTempDirectories { + const directories = new Set() + + return { + create(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)) + directories.add(directory) + return directory + }, + track(directory: string): string { + directories.add(directory) + return directory + }, + cleanup(): void { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } + directories.clear() + } + } +} \ No newline at end of file diff --git a/packages/devflare/tests/helpers/tracked-timeouts.ts b/packages/devflare/tests/helpers/tracked-timeouts.ts new file mode 100644 index 0000000..f94bb14 --- /dev/null +++ b/packages/devflare/tests/helpers/tracked-timeouts.ts @@ -0,0 +1,26 @@ +export interface TrackedTimeoutState { + scheduledTimeoutIds: number[] + clearedTimeoutIds: number[] +} + +export function installTrackedTimeouts(): TrackedTimeoutState { + const scheduledTimeoutIds: number[] = [] + const clearedTimeoutIds: number[] = [] + let nextTimeoutId = 0 + + globalThis.setTimeout = (((_handler: Parameters[0], _timeout?: number, ..._args: unknown[]) => { + const timeoutId = ++nextTimeoutId + scheduledTimeoutIds.push(timeoutId) + return timeoutId as unknown as ReturnType + }) as typeof setTimeout) + globalThis.clearTimeout = (((timeoutId?: ReturnType) => { + if (typeof timeoutId === 'number') { + clearedTimeoutIds.push(timeoutId) + } + }) as typeof clearTimeout) + + return { + scheduledTimeoutIds, + clearedTimeoutIds + } +} diff --git a/packages/devflare/tests/integration/bridge/_fixtures.ts b/packages/devflare/tests/integration/bridge/_fixtures.ts new file mode 100644 index 0000000..464f3ec --- /dev/null +++ b/packages/devflare/tests/integration/bridge/_fixtures.ts @@ -0,0 +1,308 @@ +// ============================================================================= +// Bridge Test Fixtures โ€” Shared test utilities and DO classes +// ============================================================================= + +import type { Miniflare } from 'miniflare' +import type { MiniflareInstance } from '../../../src/bridge/miniflare' + +// ============================================================================= +// Port Allocation (must be first - used by other exports) +// ============================================================================= + +// Use different ports for each test file to avoid conflicts +export const PORTS = { + miniflare: 9787, + durableObject: 9790, + bridgeProxy: 9791, + multiInstance1: 9788, + multiInstance2: 9789, + r2Transfer: 9793, + r2Large: 9794, + case18Do: 9795 +} as const + +// ============================================================================= +// Gateway Worker Script Generator (must be before scripts that use it) +// ============================================================================= + +/** + * Common executeRpc function used by all gateway workers. + * Handles KV and DO RPC operations via WebSocket bridge. + * + * Operation names follow the namespaced convention shipped by the + * production bridge proxy (see src/bridge/proxy.ts and src/bridge/server.ts): + * - kv.get / kv.put / kv.delete / kv.list + * - do.idFromName / do.fetch / do.rpc + * + * Bare-verb forms are also accepted for back-compat with older test scripts. + */ +const executeRpcScript = ` + async function executeRpc(env, method, params) { + const [bindingName, ...rest] = method.split('.') + let operation = rest.join('.') + const binding = env[bindingName] + + if (!binding) throw new Error('Binding not found: ' + bindingName) + + // Strip leading kind prefix if present so the dispatcher below can stay + // flat. (Test fixture only โ€” production server warns and translates.) + if (operation.indexOf('kv.') === 0) operation = operation.slice(3) + else if (operation.indexOf('do.') === 0) { + const tail = operation.slice(3) + if (tail === 'fetch') operation = 'stub.fetch' + else if (tail === 'rpc') operation = 'stub.rpc' + else operation = tail + } + + // KV operations + if (operation === 'get') return binding.get(params[0], params[1]) + if (operation === 'put') return binding.put(params[0], params[1], params[2]) + if (operation === 'delete') return binding.delete(params[0]) + if (operation === 'list') return binding.list(params[0]) + + // DO operations + if (operation === 'idFromName') { + const id = binding.idFromName(params[0]) + return { __type: 'DOId', hex: id.toString() } + } + if (operation === 'stub.fetch') { + const [, idSerialized, reqSerialized] = params + const id = binding.idFromString(idSerialized.hex) + const stub = binding.get(id) + const request = new Request(reqSerialized.url, { + method: reqSerialized.method, + headers: reqSerialized.headers, + body: reqSerialized.body?.data ? atob(reqSerialized.body.data) : undefined + }) + const response = await stub.fetch(request) + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + body: null + } + } + if (operation === 'stub.rpc') { + const [, idSerialized, rpcMethod, rpcParams] = params + const id = binding.idFromString(idSerialized.hex) + const stub = binding.get(id) + const response = await stub.fetch(new Request('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: rpcMethod, params: rpcParams }) + })) + const result = await response.json() + if (!result.ok) throw new Error(result.error?.message || 'RPC failed') + return result.result + } + + throw new Error('Unknown operation: ' + method) + } +` + +/** + * Generate a gateway worker script with custom DO classes. + * @param doClasses - String containing DO class definitions + * @param gatewayName - Optional name for the gateway (for logging) + */ +export function createGatewayScript(doClasses: string, gatewayName = 'Devflare Bridge Gateway'): string { + return ` +${doClasses} + + // Gateway worker that handles RPC over WebSocket + export default { + async fetch(request, env) { + const url = new URL(request.url) + + // Health check + if (url.pathname === '/_devflare/health') { + return Response.json({ ok: true, bindings: Object.keys(env) }) + } + + // WebSocket upgrade for RPC + if (request.headers.get('Upgrade') === 'websocket') { + const { 0: client, 1: server } = new WebSocketPair() + server.accept() + + server.addEventListener('message', async (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.t === 'rpc.call') { + const result = await executeRpc(env, msg.method, msg.params) + server.send(JSON.stringify({ t: 'rpc.ok', id: msg.id, result })) + } + } catch (error) { + server.send(JSON.stringify({ + t: 'rpc.err', + id: 'unknown', + error: { code: 'RPC_ERROR', message: error.message } + })) + } + }) + + return new Response(null, { status: 101, webSocket: client }) + } + + return new Response('${gatewayName}') + } + } + +${executeRpcScript} +` +} + +// ============================================================================= +// Durable Object Worker Scripts +// ============================================================================= + +/** + * Counter DO worker script with RPC support. + * This is used by both durable-object.test.ts and bridge-proxy.test.ts + */ +export const counterDoWorkerScript = ` + export class CounterDO { + constructor(state, env) { + this.state = state + this.count = 0 + this.state.blockConcurrencyWhile(async () => { + this.count = (await this.state.storage.get('count')) ?? 0 + }) + } + + async increment() { + this.count++ + await this.state.storage.put('count', this.count) + return this.count + } + + async decrement() { + this.count-- + await this.state.storage.put('count', this.count) + return this.count + } + + async getCount() { + return this.count + } + + async reset() { + this.count = 0 + await this.state.storage.put('count', 0) + return 0 + } + + async fetch(request) { + const url = new URL(request.url) + + if (url.pathname === '/_rpc' && request.method === 'POST') { + try { + const { method, params } = await request.json() + const fn = this[method] + if (typeof fn !== 'function') { + return Response.json({ ok: false, error: { message: 'Method not found: ' + method } }) + } + const result = await fn.apply(this, params) + return Response.json({ ok: true, result }) + } catch (error) { + return Response.json({ ok: false, error: { message: error.message } }) + } + } + + return new Response('Counter: ' + this.count) + } + } + + export default { + async fetch(request, env) { + return new Response('DO Worker Ready') + } + } +` + +/** + * Gateway worker script with DO RPC support. + * Used by bridge-proxy.test.ts to test the full bridge flow. + */ +export const gatewayWorkerScript = createGatewayScript(` + // Counter DO for testing RPC + export class CounterDO { + constructor(state, env) { + this.state = state + this.count = 0 + this.state.blockConcurrencyWhile(async () => { + this.count = (await this.state.storage.get('count')) ?? 0 + }) + } + + async increment() { + this.count++ + await this.state.storage.put('count', this.count) + return this.count + } + + async getCount() { + return this.count + } + + async reset() { + this.count = 0 + await this.state.storage.put('count', 0) + return 0 + } + + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === '/_rpc' && request.method === 'POST') { + try { + const { method, params } = await request.json() + const fn = this[method] + if (typeof fn !== 'function') { + return Response.json({ ok: false, error: { message: 'Method not found: ' + method } }) + } + const result = await fn.apply(this, params) + return Response.json({ ok: true, result }) + } catch (error) { + return Response.json({ ok: false, error: { message: error.message } }) + } + } + return new Response('Counter: ' + this.count) + } + } +`) + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Creates a MiniflareInstance wrapper from a raw Miniflare instance + */ +export function wrapMiniflare(miniflare: Miniflare): MiniflareInstance { + return { + ready: Promise.resolve(), + dispose: () => miniflare.dispose(), + getBindings: () => miniflare.getBindings(), + getKVNamespace: miniflare.getKVNamespace.bind(miniflare), + getR2Bucket: miniflare.getR2Bucket.bind(miniflare), + getD1Database: miniflare.getD1Database.bind(miniflare), + getDurableObjectNamespace: miniflare.getDurableObjectNamespace.bind(miniflare), + dispatchFetch: miniflare.dispatchFetch.bind(miniflare), + _mf: miniflare + } +} + +/** + * Helper to call RPC on a DO stub + */ +export async function callDoRpc( + stub: DurableObjectStub, + method: string, + params: unknown[] = [] +): Promise<{ ok: boolean; result?: unknown; error?: { message: string } }> { + const response = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, params }) + }) + return response.json() as Promise<{ ok: boolean; result?: unknown; error?: { message: string } }> +} diff --git a/packages/devflare/tests/integration/bridge/bridge-proxy.test.ts b/packages/devflare/tests/integration/bridge/bridge-proxy.test.ts new file mode 100644 index 0000000..dbed932 --- /dev/null +++ b/packages/devflare/tests/integration/bridge/bridge-proxy.test.ts @@ -0,0 +1,163 @@ +// ============================================================================= +// Bridge Proxy Integration Tests +// ============================================================================= +// Tests the full bridge pattern: BridgeClient + Proxy โ†’ Miniflare Gateway +// This demonstrates the user-facing API: env.MY_DO.getByName('name').method() +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import type { Miniflare } from 'miniflare' +import { BridgeClient } from '../../../src/bridge/client' +import { createEnvProxy, setBindingHints } from '../../../src/bridge/proxy' +import { gatewayWorkerScript, PORTS } from './_fixtures' + +// ============================================================================= +// Helper Types - RPC stubs return `any` for dynamic method access +// ============================================================================= + +/** + * RPC-enabled DurableObjectStub that allows calling any method. + * Used when testing RPC patterns where methods are dynamically invoked. + */ +type RpcStub = DurableObjectStub & Record Promise> + +/** + * DurableObjectNamespace with RPC-enabled getByName + */ +type RpcNamespace = Omit & { + getByName(name: string): RpcStub +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('Bridge Proxy Integration', () => { + let miniflare: Miniflare + let client: BridgeClient + let env: Record + + const BRIDGE_PORT = PORTS.bridgeProxy + + beforeAll(async () => { + // Start Miniflare with gateway worker + const { Miniflare } = await import('miniflare') + + miniflare = new Miniflare({ + modules: true, + script: gatewayWorkerScript, + durableObjects: { + COUNTER: 'CounterDO' + }, + kvNamespaces: ['TEST_KV'], + port: BRIDGE_PORT + }) + + await miniflare.ready + + // Create bridge client + client = new BridgeClient({ + url: `ws://localhost:${BRIDGE_PORT}` + }) + await client.connect() + + // Set up binding hints + setBindingHints({ + TEST_KV: 'kv', + COUNTER: 'do' + }) + + // Create env proxy + env = createEnvProxy({ client }) + }) + + afterAll(async () => { + await client.disconnect() + await miniflare.dispose() + }) + + describe('KV via Proxy', () => { + test('can put and get values through proxy', async () => { + const kv = env.TEST_KV as KVNamespace + await kv.put('proxy-key', 'proxy-value') + const value = await kv.get('proxy-key') + expect(value).toBe('proxy-value') + }) + + test('can delete values through proxy', async () => { + const kv = env.TEST_KV as KVNamespace + await kv.put('delete-proxy', 'value') + await kv.delete('delete-proxy') + const value = await kv.get('delete-proxy') + expect(value).toBeNull() + }) + }) + + describe('DO RPC via Proxy', () => { + test('can get DO namespace from env', () => { + const doNs = env.COUNTER as RpcNamespace + expect(doNs).toBeDefined() + expect(typeof doNs.idFromName).toBe('function') + expect(typeof doNs.getByName).toBe('function') + }) + + test('getByName returns stub with RPC methods', () => { + const doNs = env.COUNTER as RpcNamespace + const stub = doNs.getByName('test-counter') + expect(stub).toBeDefined() + expect(typeof stub.fetch).toBe('function') + }) + + test('can call DO methods via RPC proxy', async () => { + const doNs = env.COUNTER as RpcNamespace + const counter = doNs.getByName('rpc-proxy-test') + + // Reset + const resetResult = await counter.reset() + expect(resetResult).toBe(0) + + // Increment + const inc1 = await counter.increment() + expect(inc1).toBe(1) + + const inc2 = await counter.increment() + expect(inc2).toBe(2) + + // Get count + const count = await counter.getCount() + expect(count).toBe(2) + }) + + test('DO RPC preserves state across proxy calls', async () => { + const doNs = env.COUNTER as RpcNamespace + const counter = doNs.getByName('state-proxy-test') + + await counter.reset() + + // Multiple operations + await counter.increment() + await counter.increment() + await counter.increment() + + const finalCount = await counter.getCount() + expect(finalCount).toBe(3) + }) + + test('different named DOs have separate state via proxy', async () => { + const doNs = env.COUNTER as RpcNamespace + + const counterA = doNs.getByName('proxy-a') + const counterB = doNs.getByName('proxy-b') + + await counterA.reset() + await counterB.reset() + + await counterA.increment() + await counterA.increment() + await counterB.increment() + + expect(await counterA.getCount()).toBe(2) + expect(await counterB.getCount()).toBe(1) + }) + }) +}) diff --git a/packages/devflare/tests/integration/bridge/case18-do.test.ts b/packages/devflare/tests/integration/bridge/case18-do.test.ts new file mode 100644 index 0000000..07735c1 --- /dev/null +++ b/packages/devflare/tests/integration/bridge/case18-do.test.ts @@ -0,0 +1,282 @@ +// ============================================================================= +// Case18 Bridge Integration Test +// ============================================================================= +// Tests the bridge with case18's ChatRoom DO class +// This validates DO RPC patterns work with real SvelteKit app structures +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import type { Miniflare } from 'miniflare' +import { BridgeClient } from '../../../src/bridge/client' +import { createEnvProxy, setBindingHints } from '../../../src/bridge/proxy' +import { PORTS, createGatewayScript } from './_fixtures' + +// ============================================================================= +// Helper Types - RPC stubs return `any` for dynamic method access +// ============================================================================= + +/** + * RPC-enabled DurableObjectStub that allows calling any method. + * Used when testing RPC patterns where methods are dynamically invoked. + */ +type RpcStub = DurableObjectStub & Record Promise> + +/** + * DurableObjectNamespace with RPC-enabled getByName + */ +type RpcNamespace = Omit & { + getByName(name: string): RpcStub +} + +// ============================================================================= +// ChatRoom DO Class Definition +// ============================================================================= + +const chatRoomDoClass = ` + export class ChatRoom { + constructor(state, env) { + this.ctx = state + this.env = env + this.roomId = '' + } + + async fetch(request) { + const url = new URL(request.url) + + // RPC endpoint + if (url.pathname === '/_rpc' && request.method === 'POST') { + try { + const { method, params } = await request.json() + const fn = this[method] + if (typeof fn !== 'function') { + return Response.json({ ok: false, error: { message: 'Method not found: ' + method } }) + } + const result = await fn.apply(this, params) + return Response.json({ ok: true, result }) + } catch (error) { + return Response.json({ ok: false, error: { message: error.message } }) + } + } + + // Get room info + if (url.pathname === '/info') { + const messageCount = (await this.ctx.storage.list({ prefix: 'msg:' })).size + return Response.json({ + roomId: this.roomId || 'default', + messageCount, + onlineCount: this.ctx.getWebSockets?.()?.length || 0 + }) + } + + // Get message history + if (url.pathname === '/history') { + const messages = await this.ctx.storage.list({ prefix: 'msg:' }) + const history = [...messages.values()] + return Response.json({ messages: history }) + } + + return new Response('ChatRoom DO') + } + + // RPC: Get online count (returns 0 in test since no WebSockets) + getOnlineCount() { + return this.ctx.getWebSockets?.()?.length || 0 + } + + // RPC: Broadcast a system message + async broadcastSystemMessage(content) { + const id = crypto.randomUUID() + const timestamp = Date.now() + const key = 'msg:' + timestamp + ':' + id + + const message = { + id, + userId: 'system', + username: 'System', + content, + timestamp, + roomId: this.roomId || 'default' + } + + await this.ctx.storage.put(key, message) + return { success: true, messageId: id } + } + + // RPC: Get message count + async getMessageCount() { + const messages = await this.ctx.storage.list({ prefix: 'msg:' }) + return messages.size + } + + // RPC: Clear all messages + async clearMessages() { + const messages = await this.ctx.storage.list({ prefix: 'msg:' }) + if (messages.size > 0) { + await this.ctx.storage.delete([...messages.keys()]) + } + return { cleared: messages.size } + } + } +` + +// Use shared gateway generator - eliminates ~60 lines of duplicate code +const chatRoomWorkerScript = createGatewayScript(chatRoomDoClass, 'Case18 Test Gateway') + +// ============================================================================= +// Tests +// ============================================================================= + +describe('Case18 Bridge Integration', () => { + let miniflare: Miniflare + let client: BridgeClient + let env: Record + + const BRIDGE_PORT = PORTS.case18Do + + beforeAll(async () => { + // Start Miniflare with case18-like configuration + const { Miniflare } = await import('miniflare') + + miniflare = new Miniflare({ + modules: true, + script: chatRoomWorkerScript, + durableObjects: { + CHAT_ROOM: 'ChatRoom' + }, + kvNamespaces: ['CACHE'], + port: BRIDGE_PORT + }) + + await miniflare.ready + + // Create bridge client + client = new BridgeClient({ + url: `ws://localhost:${BRIDGE_PORT}` + }) + await client.connect() + + // Set up binding hints (like devflare config would) + setBindingHints({ + CHAT_ROOM: 'do', + CACHE: 'kv' + }) + + // Create env proxy + env = createEnvProxy({ client }) + }) + + afterAll(async () => { + await client.disconnect() + await miniflare.dispose() + }) + + describe('ChatRoom DO via Bridge', () => { + test('can access CHAT_ROOM namespace', () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + expect(chatRoom).toBeDefined() + expect(typeof chatRoom.idFromName).toBe('function') + expect(typeof chatRoom.getByName).toBe('function') + }) + + test('can create room via getByName', () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + const room = chatRoom.getByName('test-room') + expect(room).toBeDefined() + }) + + test('can call getOnlineCount RPC method', async () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + const room = chatRoom.getByName('rpc-count-room') + + const count = await room.getOnlineCount() + expect(count).toBe(0) + }) + + test('can call broadcastSystemMessage RPC method', async () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + const room = chatRoom.getByName('broadcast-room') + + // Clear any existing messages first + await room.clearMessages() + + // Broadcast a system message + const result = await room.broadcastSystemMessage('Hello from test!') as { success: boolean; messageId: string } + expect(result).toBeDefined() + expect(result.success).toBe(true) + expect(result.messageId).toBeDefined() + }) + + test('can call getMessageCount RPC method', async () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + const room = chatRoom.getByName('count-room') + + // Clear first + await room.clearMessages() + expect(await room.getMessageCount()).toBe(0) + + // Add messages + await room.broadcastSystemMessage('Message 1') + await room.broadcastSystemMessage('Message 2') + + // Count should be 2 + const count = await room.getMessageCount() + expect(count).toBe(2) + }) + + test('multiple rooms have separate state', async () => { + const chatRoom = env.CHAT_ROOM as RpcNamespace + + const roomA = chatRoom.getByName('separate-a') + const roomB = chatRoom.getByName('separate-b') + + // Clear both + await roomA.clearMessages() + await roomB.clearMessages() + + // Add different counts + await roomA.broadcastSystemMessage('A1') + await roomA.broadcastSystemMessage('A2') + await roomA.broadcastSystemMessage('A3') + + await roomB.broadcastSystemMessage('B1') + + // Verify separate state + expect(await roomA.getMessageCount()).toBe(3) + expect(await roomB.getMessageCount()).toBe(1) + }) + }) + + describe('KV via Bridge', () => { + test('can put and get values', async () => { + const cache = env.CACHE as KVNamespace + await cache.put('test-key', 'test-value') + const value = await cache.get('test-key') + expect(value).toBe('test-value') + }) + }) + + describe('Real-world Usage Pattern', () => { + test('simulates SvelteKit route handler pattern', async () => { + // This simulates what a SvelteKit route would do: + // const { CHAT_ROOM } = platform.env + // const id = CHAT_ROOM.idFromName(roomId) + // const stub = CHAT_ROOM.get(id) + // const response = await stub.fetch(request) + + const chatRoom = env.CHAT_ROOM as RpcNamespace + const room = chatRoom.getByName('sveltekit-pattern-room') + + // Clear room + await room.clearMessages() + + // Simulate user actions + await room.broadcastSystemMessage('User joined') + await room.broadcastSystemMessage('User sent a message') + await room.broadcastSystemMessage('User left') + + // Check final state + const count = await room.getMessageCount() + expect(count).toBe(3) + }) + }) +}) diff --git a/packages/devflare/tests/integration/bridge/durable-object.test.ts b/packages/devflare/tests/integration/bridge/durable-object.test.ts new file mode 100644 index 0000000..a396e62 --- /dev/null +++ b/packages/devflare/tests/integration/bridge/durable-object.test.ts @@ -0,0 +1,182 @@ +// ============================================================================= +// Durable Object RPC Integration Tests +// ============================================================================= +// Tests DO RPC pattern: env.MY_DO.getByName('name').methodName() +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import type { MiniflareInstance } from '../../../src/bridge/miniflare' +import { counterDoWorkerScript, wrapMiniflare, callDoRpc, PORTS } from './_fixtures' + +// ============================================================================= +// Tests +// ============================================================================= + +describe('Durable Object Integration', () => { + let mf: MiniflareInstance + + beforeAll(async () => { + const { Miniflare } = await import('miniflare') + + const miniflare = new Miniflare({ + modules: true, + script: counterDoWorkerScript, + durableObjects: { + COUNTER: 'CounterDO' + }, + port: PORTS.durableObject + }) + + await miniflare.ready + mf = wrapMiniflare(miniflare) + }) + + afterAll(async () => { + await mf.dispose() + }) + + describe('DO Namespace Operations', () => { + test('can get DO namespace from Miniflare', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + expect(ns).toBeDefined() + expect(typeof ns.idFromName).toBe('function') + }) + + test('can create ID from name', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + const id = ns.idFromName('test-counter') + expect(id).toBeDefined() + expect(typeof id.toString).toBe('function') + }) + + test('can get stub from ID', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + const id = ns.idFromName('test-counter-2') + const stub = ns.get(id) + expect(stub).toBeDefined() + expect(typeof stub.fetch).toBe('function') + }) + }) + + describe('DO Fetch Operations', () => { + test('can fetch from DO stub', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + const id = ns.idFromName('fetch-test') + const stub = ns.get(id) + + const response = await stub.fetch('http://do/') + expect(response.status).toBe(200) + const text = await response.text() + expect(text).toContain('Counter:') + }) + }) + + describe('DO RPC Pattern', () => { + test('can call RPC method via fetch to /_rpc', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + const id = ns.idFromName('rpc-test') + const stub = ns.get(id) + + // Reset first + const resetRes = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'reset', params: [] }) + }) + const resetData = await resetRes.json() as { ok: boolean; result: number } + expect(resetData.ok).toBe(true) + expect(resetData.result).toBe(0) + + // Increment + const incRes = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'increment', params: [] }) + }) + const incData = await incRes.json() as { ok: boolean; result: number } + expect(incData.ok).toBe(true) + expect(incData.result).toBe(1) + + // Get count + const getRes = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'getCount', params: [] }) + }) + const getData = await getRes.json() as { ok: boolean; result: number } + expect(getData.ok).toBe(true) + expect(getData.result).toBe(1) + }) + + test('RPC preserves state across calls', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + const id = ns.idFromName('state-test') + const stub = ns.get(id) + + // Helper to call RPC + const callRpc = async (method: string, params: unknown[] = []) => { + const res = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, params }) + }) + return (await res.json()) as { ok: boolean; result: number } + } + + // Reset + await callRpc('reset') + + // Multiple increments + const r1 = await callRpc('increment') + const r2 = await callRpc('increment') + const r3 = await callRpc('increment') + + expect(r1.result).toBe(1) + expect(r2.result).toBe(2) + expect(r3.result).toBe(3) + + // Decrement + const r4 = await callRpc('decrement') + expect(r4.result).toBe(2) + + // Final count + const r5 = await callRpc('getCount') + expect(r5.result).toBe(2) + }) + + test('different DO instances have separate state', async () => { + const ns = await mf.getDurableObjectNamespace('COUNTER') + + // Helper to call RPC on a specific instance + const callRpc = async (name: string, method: string, params: unknown[] = []) => { + const id = ns.idFromName(name) + const stub = ns.get(id) + const res = await stub.fetch('http://do/_rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, params }) + }) + return (await res.json()) as { ok: boolean; result: number } + } + + // Reset both + await callRpc('instance-a', 'reset') + await callRpc('instance-b', 'reset') + + // Increment A three times + await callRpc('instance-a', 'increment') + await callRpc('instance-a', 'increment') + await callRpc('instance-a', 'increment') + + // Increment B once + await callRpc('instance-b', 'increment') + + // Verify separate state + const countA = await callRpc('instance-a', 'getCount') + const countB = await callRpc('instance-b', 'getCount') + + expect(countA.result).toBe(3) + expect(countB.result).toBe(1) + }) + }) +}) diff --git a/packages/devflare/tests/integration/bridge/miniflare.test.ts b/packages/devflare/tests/integration/bridge/miniflare.test.ts new file mode 100644 index 0000000..a9dfef8 --- /dev/null +++ b/packages/devflare/tests/integration/bridge/miniflare.test.ts @@ -0,0 +1,146 @@ +// ============================================================================= +// Bridge Integration Tests โ€” Miniflare Orchestration & DO RPC +// ============================================================================= +// Tests the full bridge stack: Miniflare โ†’ Gateway Worker โ†’ RPC โ†’ Proxy +// ============================================================================= + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type MiniflareInstance, startMiniflare, stopMiniflare } from '../../../src/bridge/miniflare' +import { PORTS } from './_fixtures' + +describe('Miniflare Orchestration', () => { + let mf: MiniflareInstance + + beforeAll(async () => { + mf = await startMiniflare({ + port: PORTS.miniflare, + kvNamespaces: ['TEST_KV'], + persist: false, + verbose: false + }) + }) + + afterAll(async () => { + await mf.dispose() + }) + + describe('KV Namespace', () => { + test('can get and put values', async () => { + const kv = await mf.getKVNamespace('TEST_KV') + await kv.put('test-key', 'test-value') + const value = await kv.get('test-key', 'text') + expect(value).toBe('test-value') + }) + + test('can delete values', async () => { + const kv = await mf.getKVNamespace('TEST_KV') + await kv.put('delete-me', 'value') + await kv.delete('delete-me') + const value = await kv.get('delete-me') + expect(value).toBeNull() + }) + + test('can list keys', async () => { + const kv = await mf.getKVNamespace('TEST_KV') + await kv.put('list-a', 'a') + await kv.put('list-b', 'b') + const result = await kv.list({ prefix: 'list-' }) + expect(result.keys.length).toBeGreaterThanOrEqual(2) + expect(result.keys.some((k) => k.name === 'list-a')).toBe(true) + expect(result.keys.some((k) => k.name === 'list-b')).toBe(true) + }) + + test('returns null for non-existent keys', async () => { + const kv = await mf.getKVNamespace('TEST_KV') + const value = await kv.get('non-existent-key') + expect(value).toBeNull() + }) + }) + + describe('dispatchFetch', () => { + test('can dispatch requests to gateway', async () => { + const response = await mf.dispatchFetch('http://localhost/') + expect(response.status).toBe(200) + const text = await response.text() + expect(text).toContain('Devflare') + }) + + test('health check returns binding info', async () => { + const response = await mf.dispatchFetch('http://localhost/_devflare/health') + expect(response.status).toBe(200) + const data = await response.json() as { status?: string; ok?: boolean; bindings: string[] } + expect(data.bindings).toContain('TEST_KV') + }) + }) +}) + +describe('Multiple Miniflare Instances', () => { + test('can run separate instances on different ports', async () => { + const mf1 = await startMiniflare({ + port: PORTS.multiInstance1, + kvNamespaces: ['KV1'], + persist: false + }) + + const mf2 = await startMiniflare({ + port: PORTS.multiInstance2, + kvNamespaces: ['KV2'], + persist: false + }) + + try { + // Each instance has its own bindings + const kv1 = await mf1.getKVNamespace('KV1') + const kv2 = await mf2.getKVNamespace('KV2') + + await kv1.put('instance', '1') + await kv2.put('instance', '2') + + expect(await kv1.get('instance', 'text')).toBe('1') + expect(await kv2.get('instance', 'text')).toBe('2') + } finally { + await mf1.dispose() + await mf2.dispose() + } + }) + + test('uses a string persist value as the persistence directory', async () => { + const persistDir = await mkdtemp(join(tmpdir(), 'devflare-miniflare-persist-')) + const persistPort = PORTS.case18Do + 1 + + try { + const firstInstance = await startMiniflare({ + port: persistPort, + kvNamespaces: ['PERSIST_KV'], + persist: persistDir + }) + + try { + const kv = await firstInstance.getKVNamespace('PERSIST_KV') + await kv.put('persisted-key', 'persisted-value') + } finally { + await firstInstance.dispose() + } + + expect((await readdir(persistDir)).length).toBeGreaterThan(0) + + const secondInstance = await startMiniflare({ + port: persistPort, + kvNamespaces: ['PERSIST_KV'], + persist: persistDir + }) + + try { + const kv = await secondInstance.getKVNamespace('PERSIST_KV') + expect(await kv.get('persisted-key', 'text')).toBe('persisted-value') + } finally { + await secondInstance.dispose() + } + } finally { + await rm(persistDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/devflare/tests/integration/bridge/r2-transfer.test.ts b/packages/devflare/tests/integration/bridge/r2-transfer.test.ts new file mode 100644 index 0000000..3fa40d2 --- /dev/null +++ b/packages/devflare/tests/integration/bridge/r2-transfer.test.ts @@ -0,0 +1,224 @@ +// ============================================================================= +// R2 HTTP Transfer Integration Tests +// ============================================================================= +// Tests large file upload/download via HTTP transfer path +// ============================================================================= + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { startMiniflare, type MiniflareInstance } from '../../../src/bridge/miniflare' +import { PORTS } from './_fixtures' + +describe('R2 HTTP Transfer', () => { + let mf: MiniflareInstance + + beforeAll(async () => { + mf = await startMiniflare({ + port: PORTS.r2Transfer, + r2Buckets: ['TEST_BUCKET'], + persist: false, + verbose: false + }) + }) + + afterAll(async () => { + await mf.dispose() + }) + + describe('Direct R2 Operations via Miniflare', () => { + test('can put and get small files', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + const content = 'Hello, R2!' + + await r2.put('small-file.txt', content) + + const obj = await r2.get('small-file.txt') + expect(obj).not.toBeNull() + const text = await obj!.text() + expect(text).toBe(content) + }) + + test('can put and get binary data', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + const data = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + + await r2.put('binary-file.bin', data) + + const obj = await r2.get('binary-file.bin') + expect(obj).not.toBeNull() + const buffer = await obj!.arrayBuffer() + expect(new Uint8Array(buffer)).toEqual(data) + }) + + test('can put large files (1MB+)', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + + // Create 1MB of data + const size = 1024 * 1024 + const data = new Uint8Array(size) + for (let i = 0; i < size; i++) { + data[i] = i % 256 + } + + await r2.put('large-file.bin', data) + + const obj = await r2.get('large-file.bin') + expect(obj).not.toBeNull() + expect(obj!.size).toBe(size) + + // Verify first and last bytes + const buffer = await obj!.arrayBuffer() + const result = new Uint8Array(buffer) + expect(result[0]).toBe(0) + expect(result[255]).toBe(255) + expect(result[256]).toBe(0) + expect(result[size - 1]).toBe((size - 1) % 256) + }) + + test('can head files without downloading', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + await r2.put('head-test.txt', 'Some content for head test') + + const obj = await r2.head('head-test.txt') + expect(obj).not.toBeNull() + expect(obj!.key).toBe('head-test.txt') + expect(obj!.size).toBe(26) + }) + + test('can delete files', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + await r2.put('delete-me.txt', 'To be deleted') + + // Verify it exists + let obj = await r2.get('delete-me.txt') + expect(obj).not.toBeNull() + + // Delete it + await r2.delete('delete-me.txt') + + // Verify it's gone + obj = await r2.get('delete-me.txt') + expect(obj).toBeNull() + }) + + test('can list files', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + + // Put some files with prefix + await r2.put('list-test/a.txt', 'a') + await r2.put('list-test/b.txt', 'b') + await r2.put('list-test/c.txt', 'c') + + const result = await r2.list({ prefix: 'list-test/' }) + expect(result.objects.length).toBeGreaterThanOrEqual(3) + + const keys = result.objects.map((o) => o.key) + expect(keys).toContain('list-test/a.txt') + expect(keys).toContain('list-test/b.txt') + expect(keys).toContain('list-test/c.txt') + }) + + test('can store and retrieve with metadata', async () => { + const r2 = await mf.getR2Bucket('TEST_BUCKET') + + await r2.put('with-metadata.txt', 'Content with metadata', { + httpMetadata: { + contentType: 'text/plain', + contentLanguage: 'en-US' + }, + customMetadata: { + author: 'Test', + version: '1.0' + } + }) + + const obj = await r2.get('with-metadata.txt') + expect(obj).not.toBeNull() + expect(obj!.httpMetadata?.contentType).toBe('text/plain') + expect(obj!.customMetadata?.author).toBe('Test') + expect(obj!.customMetadata?.version).toBe('1.0') + }) + }) + + describe('Gateway HTTP Transfer Endpoint', () => { + test('health endpoint lists R2 bucket', async () => { + const response = await mf.dispatchFetch('http://localhost/_devflare/health') + expect(response.status).toBe(200) + + const data = await response.json() as { status?: string; ok?: boolean; bindings: string[] } + expect(data.bindings).toContain('TEST_BUCKET') + }) + }) +}) + +describe('R2 Large File Simulation', () => { + let mf: MiniflareInstance + + beforeAll(async () => { + mf = await startMiniflare({ + port: PORTS.r2Large, + r2Buckets: ['LARGE_BUCKET'], + persist: false + }) + }) + + afterAll(async () => { + await mf.dispose() + }) + + test('can handle 5MB file', async () => { + const r2 = await mf.getR2Bucket('LARGE_BUCKET') + + // Create 5MB of data + const size = 5 * 1024 * 1024 + const data = new Uint8Array(size) + // Fill with pattern + for (let i = 0; i < size; i++) { + data[i] = (i * 7) % 256 + } + + const startPut = Date.now() + await r2.put('5mb-file.bin', data) + const putTime = Date.now() - startPut + + const startGet = Date.now() + const obj = await r2.get('5mb-file.bin') + const getTime = Date.now() - startGet + + expect(obj).not.toBeNull() + expect(obj!.size).toBe(size) + + // Verify integrity by checking some bytes + const buffer = await obj!.arrayBuffer() + const result = new Uint8Array(buffer) + expect(result[0]).toBe(0) + expect(result[1000]).toBe((1000 * 7) % 256) + expect(result[1000000]).toBe((1000000 * 7) % 256) + + }) + + test('can handle Blob upload (simulating stream)', async () => { + const r2 = await mf.getR2Bucket('LARGE_BUCKET') + + // Create 1MB of data as Blob (has known length, unlike streams) + const size = 1024 * 1024 + const data = new Uint8Array(size) + for (let i = 0; i < size; i++) { + data[i] = (i * 7) % 256 + } + + const blob = new Blob([data], { type: 'application/octet-stream' }) + + // Miniflare R2 accepts Blob directly + await r2.put('blob-file.bin', blob as Parameters[1]) + + const obj = await r2.get('blob-file.bin') + expect(obj).not.toBeNull() + expect(obj!.size).toBe(size) + + // Verify integrity + const buffer = await obj!.arrayBuffer() + const result = new Uint8Array(buffer) + expect(result[0]).toBe(0) + expect(result[1000]).toBe((1000 * 7) % 256) + }) +}) diff --git a/packages/devflare/tests/integration/cli/build-deploy-worker-only.test-utils.ts b/packages/devflare/tests/integration/cli/build-deploy-worker-only.test-utils.ts new file mode 100644 index 0000000..1c6553b --- /dev/null +++ b/packages/devflare/tests/integration/cli/build-deploy-worker-only.test-utils.ts @@ -0,0 +1,648 @@ +import { mkdir, writeFile, readFile } from 'node:fs/promises' +import { dirname, join } from 'pathe' +import { runDeployCommand } from '../../../src/cli/commands/deploy' +import { setDependencies } from '../../../src/cli/dependencies' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger, type TestLogger } from '../../helpers/mock-logger' +import { + createCliDependencies, + createProcessRunner, + successResult, + type ExecInvocation +} from '../../helpers/process-runner' +export { + createCliDependencies, + createProcessRunner, + successResult, + type ExecInvocation +} from '../../helpers/process-runner' +export { createLogger, type TestLogger } + +export const TEST_ACCOUNT_ID = '0123456789abcdef0123456789abcdef' + +export interface DeployEnvironmentSnapshot { + fetch: typeof fetch + token?: string + accountId?: string + verifyDeployment?: string + verifyDeploymentDelayMs?: string + requireFreshProductionDeployment?: string + deployMetadataPath?: string +} + +export function cloudflareApiResponse(result: unknown): Response { + return jsonResponse(result) +} + +export function captureDeployEnvironmentSnapshot(): DeployEnvironmentSnapshot { + return { + fetch: globalThis.fetch, + token: process.env.CLOUDFLARE_API_TOKEN, + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + verifyDeployment: process.env.DEVFLARE_VERIFY_DEPLOYMENT, + verifyDeploymentDelayMs: process.env.DEVFLARE_VERIFY_DEPLOYMENT_DELAY_MS, + requireFreshProductionDeployment: process.env.DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT, + deployMetadataPath: process.env.DEVFLARE_DEPLOY_METADATA_PATH + } +} + +function restoreOptionalEnvironmentVariable(name: string, value: string | undefined): void { + if (typeof value === 'undefined') { + delete process.env[name] + return + } + + process.env[name] = value +} + +export function restoreDeployEnvironmentSnapshot(snapshot: DeployEnvironmentSnapshot): void { + globalThis.fetch = snapshot.fetch + restoreOptionalEnvironmentVariable('CLOUDFLARE_API_TOKEN', snapshot.token) + restoreOptionalEnvironmentVariable('CLOUDFLARE_ACCOUNT_ID', snapshot.accountId) + restoreOptionalEnvironmentVariable('DEVFLARE_VERIFY_DEPLOYMENT', snapshot.verifyDeployment) + restoreOptionalEnvironmentVariable('DEVFLARE_VERIFY_DEPLOYMENT_DELAY_MS', snapshot.verifyDeploymentDelayMs) + restoreOptionalEnvironmentVariable('DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT', snapshot.requireFreshProductionDeployment) + restoreOptionalEnvironmentVariable('DEVFLARE_DEPLOY_METADATA_PATH', snapshot.deployMetadataPath) +} + +export function enableStrictDeployVerification(options: { + token?: string + accountId?: string + delayMs?: string + requireFreshProductionDeployment?: boolean +} = {}): void { + process.env.CLOUDFLARE_API_TOKEN = options.token ?? 'test-token' + delete process.env.CLOUDFLARE_ACCOUNT_ID + if (options.accountId) { + process.env.CLOUDFLARE_ACCOUNT_ID = options.accountId + } + process.env.DEVFLARE_VERIFY_DEPLOYMENT = 'true' + process.env.DEVFLARE_VERIFY_DEPLOYMENT_DELAY_MS = options.delayMs ?? '0' + + if (options.requireFreshProductionDeployment === true) { + process.env.DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT = 'true' + } else { + delete process.env.DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT + } +} + +export function disableCloudflareAccountResolution(): void { + delete process.env.CLOUDFLARE_API_TOKEN + delete process.env.CLOUDFLARE_ACCOUNT_ID + delete process.env.DEVFLARE_VERIFY_DEPLOYMENT + delete process.env.DEVFLARE_VERIFY_DEPLOYMENT_DELAY_MS + delete process.env.DEVFLARE_REQUIRE_FRESH_PRODUCTION_DEPLOYMENT +} + +export function createDeployHarness( + processRunner: Parameters[0] = () => successResult() +): { + executions: ExecInvocation[] + logger: TestLogger +} { + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(processRunner, executions))) + return { + executions, + logger + } +} + +export async function runWorkerOnlyDeploy( + projectDir: string, + logger: TestLogger +) { + return await runDeployCommand( + { command: 'deploy', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) +} + +export function createWranglerDeployProcessRunner(options: { + stdout?: string + structuredOutput?: Record +} = {}): Parameters[0] { + return async (command, args, executionOptions) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + if (options.structuredOutput) { + const outputFilePath = String((executionOptions?.env as Record | undefined)?.WRANGLER_OUTPUT_FILE_PATH ?? '') + await writeFile(outputFilePath, JSON.stringify(options.structuredOutput)) + } + + return successResult(options.stdout ?? 'Deployed successfully to https://worker-build-test.example.workers.dev') + } + + return successResult() + } +} + +export function createWorkerVersionDetail( + id: string, + options: { + hasPreview?: boolean + source?: string + createdOn?: string + modifiedOn?: string + number?: number + authorId?: string + } = {} +): Record { + return { + id, + ...(typeof options.number === 'number' ? { number: options.number } : {}), + metadata: { + ...(options.authorId ? { author_id: options.authorId } : {}), + ...(options.createdOn ? { created_on: options.createdOn } : {}), + ...(options.modifiedOn ? { modified_on: options.modifiedOn } : {}), + has_preview: options.hasPreview === true, + source: options.source ?? 'wrangler' + } + } +} + +export function createWorkerVersionsList(items: Array>): Response { + return cloudflareApiResponse({ items }) +} + +export function createWorkerDeployment( + id: string, + versionId: string, + options: { + createdOn?: string + source?: string + strategy?: string + authorEmail?: string + annotations?: Record + percentage?: number + } = {} +): Record { + return { + id, + created_on: options.createdOn ?? new Date().toISOString(), + source: options.source ?? 'wrangler', + strategy: options.strategy ?? 'percentage', + versions: [ + { + percentage: options.percentage ?? 100, + version_id: versionId + } + ], + ...(options.annotations ? { annotations: options.annotations } : {}), + ...(options.authorEmail ? { author_email: options.authorEmail } : {}) + } +} + +export function createWorkerDeploymentsList( + deployments: Array> +): Response { + return cloudflareApiResponse({ deployments }) +} + +export async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, JSON.stringify(value, null, 2)) +} + +const DEFAULT_DEV_DEPENDENCIES = { + devflare: '^1.0.0' +} as const + +const DEFAULT_FETCH_HANDLER_SOURCE = ` +export async function fetch(): Promise { + return new Response('ok') +} +`.trim() + +async function writeProjectFixture( + projectDir: string, + options: { + packageName: string + configSource: string + files: Record + devDependencies?: Record + } +): Promise { + await writeJson(join(projectDir, 'package.json'), { + name: options.packageName, + private: true, + type: 'module', + devDependencies: options.devDependencies ?? DEFAULT_DEV_DEPENDENCIES + }) + + for (const [relativePath, content] of Object.entries({ + 'devflare.config.ts': options.configSource, + ...options.files + })) { + const absolutePath = join(projectDir, relativePath) + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, content.trim()) + } + +} + +async function writeLocalViteInstall(projectDir: string): Promise { + await mkdir(join(projectDir, 'node_modules', 'vite', 'bin'), { recursive: true }) + await writeJson(join(projectDir, 'node_modules', 'vite', 'package.json'), { + name: 'vite', + version: '8.0.7', + type: 'module', + bin: { + vite: 'bin/vite.js' + } + }) + await writeFile(join(projectDir, 'node_modules', 'vite', 'bin', 'vite.js'), ` +#!/usr/bin/env node +console.log('stub vite binary') +`.trim()) +} + +export async function writeProjectFiles( + projectDir: string, + options: { + withViteConfig?: boolean + withViteDeps?: boolean + withInlineViteConfig?: boolean + passthroughMain?: string + } = {} +): Promise { + const inlineViteConfig = options.withInlineViteConfig + ? `, + vite: { + define: { + __INLINE_VITE__: ${JSON.stringify(JSON.stringify('true'))} + } + }` + : '' + + await writeProjectFixture(projectDir, { + packageName: 'worker-build-test', + devDependencies: { + ...DEFAULT_DEV_DEPENDENCIES, + ...(options.withViteDeps + ? { + vite: '^6.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + : {}) + }, + configSource: ` +export default { + name: 'worker-build-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }${inlineViteConfig}${options.passthroughMain + ? `, + wrangler: { + passthrough: { + main: '${options.passthroughMain}' + } + }` + : ''} +} +`.trim(), + files: { + 'src/fetch.ts': DEFAULT_FETCH_HANDLER_SOURCE, + ...(options.withViteConfig + ? { + 'vite.config.ts': ` +import { defineConfig } from 'vite' + +export default defineConfig({}) +`.trim() + } + : {}) + } + }) + + if (options.withViteDeps) { + await writeLocalViteInstall(projectDir) + } +} + +export async function writeAccountProjectFiles( + projectDir: string, + options: { + workerName?: string + accountId?: string + } = {} +): Promise { + const workerName = options.workerName ?? 'worker-build-test' + const accountId = options.accountId ?? TEST_ACCOUNT_ID + + await writeProjectFixture(projectDir, { + packageName: workerName, + configSource: ` +export default { + name: ${JSON.stringify(workerName)}, + accountId: ${JSON.stringify(accountId)}, + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +} +`.trim(), + files: { + 'src/fetch.ts': DEFAULT_FETCH_HANDLER_SOURCE + } + }) +} + +export async function writeNamedD1ProjectFiles( + projectDir: string, + options: { + workerName?: string + accountId?: string + databaseName?: string + } = {} +): Promise { + const workerName = options.workerName ?? 'worker-build-test' + const accountId = options.accountId ?? 'account-123' + const databaseName = options.databaseName ?? 'app-db' + + await writeProjectFixture(projectDir, { + packageName: workerName, + configSource: ` +export default { + name: ${JSON.stringify(workerName)}, + accountId: ${JSON.stringify(accountId)}, + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + d1: { + DB: ${JSON.stringify(databaseName)} + } + } +} +`.trim(), + files: { + 'src/fetch.ts': DEFAULT_FETCH_HANDLER_SOURCE + } + }) +} + +export async function writeRequestWideHandleProjectFiles(projectDir: string): Promise { + await writeProjectFixture(projectDir, { + packageName: 'worker-build-test', + configSource: ` +export default { + name: 'worker-build-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +import { sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' + +async function authHandle(event: FetchEvent, resolve: ResolveFetch): Promise { + return resolve(event) +} + +export const handle = sequence(authHandle) + +export async function GET(): Promise { + return new Response('ok') +} +`.trim() + } + }) +} + +export async function writeRolldownWorkerProjectFiles(projectDir: string): Promise { + await writeProjectFixture(projectDir, { + packageName: 'worker-build-test', + configSource: ` +export default { + name: 'worker-build-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + rolldown: { + options: { + plugins: [{ + name: 'inline-svelte-heading', + transform(code, id) { + if (!id.endsWith('Greeting.svelte')) { + return null + } + + const heading = code.match(/

(.*?)<\\/h1>/)?.[1] ?? 'Hello from Svelte' + return { + code: 'export default function renderGreeting() { return ' + JSON.stringify(heading) + ' }', + map: null + } + } + }] + } + } +} +`.trim(), + files: { + 'src/Greeting.svelte': ` +

Hello from Svelte

+`.trim(), + 'src/fetch.ts': ` +import renderGreeting from './Greeting.svelte' + +export async function fetch(): Promise { + return new Response(renderGreeting()) +} +`.trim() + } + }) +} + +export async function writeMultiSurfaceProjectFiles( + projectDir: string, + options: { + passthroughMain?: string + } = {} +): Promise { + await writeProjectFixture(projectDir, { + packageName: 'worker-build-test', + configSource: ` +export default { + name: 'worker-build-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts' + }, + bindings: { + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue' + } + ] + } + }, + triggers: { + crons: ['0 * * * *'] + }${options.passthroughMain + ? `, + wrangler: { + passthrough: { + main: '${options.passthroughMain}' + } + }` + : '' + } +} + `.trim(), + files: { + 'src/fetch.ts': DEFAULT_FETCH_HANDLER_SOURCE, + 'src/queue.ts': ` +export async function queue() { + return undefined +} + `.trim(), + 'src/scheduled.ts': ` +export async function scheduled() { + return undefined +} + `.trim(), + 'src/email.ts': ` +export async function email() { + return undefined +} + `.trim(), + ...(options.passthroughMain + ? { + [options.passthroughMain]: ` +export async function fetch(): Promise { + return new Response('custom') +} + `.trim() + } + : {}) + } + }) +} + +export async function writeServiceBindingProjectFiles(projectDir: string): Promise { + await writeProjectFixture(projectDir, { + packageName: 'worker-build-test', + configSource: ` +export default { + name: 'gateway-worker', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + AUTH: { + service: 'auth-worker', + entrypoint: 'AdminEntrypoint' + } + } + } +} +`.trim(), + files: { + 'src/fetch.ts': DEFAULT_FETCH_HANDLER_SOURCE + } + }) +} + +export async function writeRouteProjectFiles(projectDir: string): Promise { + await writeProjectFixture(projectDir, { + packageName: 'worker-build-route-test', + configSource: ` +export default { + name: 'worker-build-route-test', + compatibilityDate: '2026-03-17', + files: { + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +} +`.trim(), + files: { + 'src/routes/index.ts': ` +export async function GET(): Promise { + return new Response('root') +} +`.trim(), + 'src/routes/users/[id].ts': ` +export async function GET(event): Promise { + return new Response(String(event.params.id)) +} +`.trim() + } + }) +} + +export async function readGeneratedDevConfig(projectDir: string): Promise { + return readFile(join(projectDir, '.devflare', 'wrangler.jsonc'), 'utf8') +} + +export async function readGeneratedDeployConfig(projectDir: string): Promise { + // R2: deploy now writes the resolved (ID-substituted) wrangler config to + // `.devflare/deploy/wrangler.jsonc` instead of overwriting the build + // artefact. Fall back to the legacy build path for tests/cases that + // only exercise the build artefact (no deploy step run yet). + const deployPath = join(projectDir, '.devflare', 'deploy', 'wrangler.jsonc') + try { + return await readFile(deployPath, 'utf8') + } catch { + return readFile(join(projectDir, '.devflare', 'build', 'wrangler.jsonc'), 'utf8') + } +} + +export function isViteBuildExecution(command: string, args: string[]): boolean { + const normalizedCommand = command.replace(/\\/g, '/') + + if (normalizedCommand.endsWith('/node_modules/vite/bin/vite.js')) { + return args[0] === 'build' + } + + // `bun --bun build โ€ฆ` (devflare's preferred Bun-runtime spawn) + if (command === 'bun') { + const bunFlagIndex = args.indexOf('--bun') + const viteIndex = args.findIndex((arg) => arg.replace(/\\/g, '/').endsWith('/node_modules/vite/bin/vite.js')) + if (bunFlagIndex >= 0 && viteIndex >= 0 && args[viteIndex + 1] === 'build') { + return true + } + } + + if (command === 'bunx') { + const viteIndex = args.indexOf('vite') + return viteIndex >= 0 && args[viteIndex + 1] === 'build' + } + + return false +} + +/** + * Extract the actual Vite entry script path from a captured execution, + * regardless of whether it was spawned directly or through `bun --bun`. + * Used by tests that assert workspace-local resolution. + */ +export function extractViteEntryPath(execution: { command: string; args: string[] }): string { + if (execution.command === 'bun') { + const viteIndex = execution.args.findIndex((arg) => + arg.replace(/\\/g, '/').endsWith('/node_modules/vite/bin/vite.js') + ) + if (viteIndex >= 0) { + return execution.args[viteIndex] ?? '' + } + } + return execution.command +} diff --git a/packages/devflare/tests/integration/cli/build-deploy-worker-only.test.ts b/packages/devflare/tests/integration/cli/build-deploy-worker-only.test.ts new file mode 100644 index 0000000..bea468a --- /dev/null +++ b/packages/devflare/tests/integration/cli/build-deploy-worker-only.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { access, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { runBuildCommand } from '../../../src/cli/commands/build' +import { + createCliDependencies, + createLogger, + createProcessRunner, + extractViteEntryPath, + isViteBuildExecution, + readGeneratedDeployConfig, + readGeneratedDevConfig, + successResult, + writeMultiSurfaceProjectFiles, + writeNamedD1ProjectFiles, + writeProjectFiles, + writeRequestWideHandleProjectFiles, + writeRolldownWorkerProjectFiles, + writeRouteProjectFiles, + writeServiceBindingProjectFiles, + type ExecInvocation +} from './build-deploy-worker-only.test-utils' + +const originalFetch = globalThis.fetch + +function createBuildHarness( + processRunner: Parameters[0] = () => successResult() +): { + executions: ExecInvocation[] + logger: ReturnType +} { + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(processRunner, executions))) + return { + executions, + logger + } +} + +async function runSuccessfulBuild( + projectDir: string, + logger: ReturnType +) { + const result = await runBuildCommand( + { command: 'build', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + if (result.exitCode !== 0) { + throw new Error(logger.messages.map((message) => `[${message.level}] ${message.args.join(' ')}`).join('\n')) + } + + expect(result.exitCode).toBe(0) + return result +} + +describe('build/deploy worker-only behavior', () => { + let projectDir = '' + + beforeEach(async () => { + clearDependencies() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-build-worker-only-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + globalThis.fetch = originalFetch + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('build preserves named D1 bindings without querying Cloudflare', async () => { + await writeNamedD1ProjectFiles(projectDir) + globalThis.fetch = (async () => { + throw new Error('build should not query Cloudflare') + }) as unknown as typeof fetch + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const deployConfig = await readGeneratedDeployConfig(projectDir) + expect(deployConfig).toContain('"database_name": "app-db"') + expect(deployConfig).not.toContain('"database_id":') + }) + + test('build skips vite for worker-only projects with no local vite.config', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + + const { executions, logger } = createBuildHarness( + (command, args) => { + if (isViteBuildExecution(command, args)) { + throw new Error('vite build should not run for worker-only build') + } + + return successResult() + } + ) + + await runSuccessfulBuild(projectDir, logger) + expect(executions.some(({ command, args }) => isViteBuildExecution(command, args))).toBe(false) + expect(logger.messages.some((message) => message.args.join(' ').includes('Skipping Vite build'))).toBe(true) + await access(join(projectDir, '.devflare', 'wrangler.jsonc')) + await access(join(projectDir, '.devflare', 'build', 'wrangler.jsonc')) + await access(join(projectDir, '.wrangler', 'deploy', 'config.json')) + }) + + test('build still runs vite when the current package has a local vite.config', async () => { + await writeProjectFiles(projectDir, { + withViteConfig: true, + withViteDeps: true, + passthroughMain: 'src/fetch.ts' + }) + + const { executions, logger } = createBuildHarness( + (command, args) => successResult(`${command} ${args.join(' ')}`) + ) + + await runSuccessfulBuild(projectDir, logger) + const viteBuildExecution = executions.find(({ command, args }) => isViteBuildExecution(command, args)) + expect(viteBuildExecution).toBeDefined() + expect(extractViteEntryPath(viteBuildExecution!).replace(/\\/g, '/')).toContain('/node_modules/vite/bin/vite.js') + await access(join(projectDir, '.devflare', 'vite.config.mjs')) + }) + + test('build runs vite with a generated config when devflare.config.ts contains inline vite config', async () => { + await writeProjectFiles(projectDir, { + withViteConfig: false, + withViteDeps: true, + withInlineViteConfig: true, + passthroughMain: 'src/fetch.ts' + }) + + const { executions, logger } = createBuildHarness( + (command, args) => successResult(`${command} ${args.join(' ')}`) + ) + + await runSuccessfulBuild(projectDir, logger) + const viteBuildExecution = executions.find(({ command, args }) => isViteBuildExecution(command, args)) + expect(viteBuildExecution).toBeDefined() + expect(extractViteEntryPath(viteBuildExecution!).replace(/\\/g, '/')).toContain('/node_modules/vite/bin/vite.js') + expect(viteBuildExecution?.args).toContain('--config') + await access(join(projectDir, '.devflare', 'vite.config.mjs')) + }) + + test('build preserves named service binding entrypoints in generated wrangler output', async () => { + await writeServiceBindingProjectFiles(projectDir) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const wranglerConfig = await readGeneratedDevConfig(projectDir) + expect(wranglerConfig).toContain('"service": "auth-worker"') + expect(wranglerConfig).toContain('"entrypoint": "AdminEntrypoint"') + }) + + test('build generates a composed worker entry for fetch-only request-wide handle middleware', async () => { + await writeRequestWideHandleProjectFiles(projectDir) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const wranglerConfig = await readGeneratedDevConfig(projectDir) + expect(wranglerConfig).toContain('"main": "worker-entrypoints/main.js"') + + const composedEntry = await readFile(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'), 'utf8') + expect(composedEntry).toContain('src/fetch.ts') + expect(composedEntry).toContain('invokeFetchModule') + }) + + test('build generates a composed worker entry when queue, scheduled, or email files are configured', async () => { + await writeMultiSurfaceProjectFiles(projectDir) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const wranglerConfig = await readGeneratedDevConfig(projectDir) + expect(wranglerConfig).toContain('"main": "worker-entrypoints/main.js"') + + const composedEntry = await readFile(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'), 'utf8') + expect(composedEntry).toContain('src/fetch.ts') + expect(composedEntry).toContain('src/queue.ts') + expect(composedEntry).toContain('src/scheduled.ts') + expect(composedEntry).toContain('src/email.ts') + + const deployConfig = await readGeneratedDeployConfig(projectDir) + expect(deployConfig).toContain('"main": "./worker.js"') + }) + + test('build generates a composed worker entry for configured file routes without src/fetch.ts', async () => { + await writeRouteProjectFiles(projectDir) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const wranglerConfig = await readGeneratedDevConfig(projectDir) + expect(wranglerConfig).toContain('"main": "worker-entrypoints/main.js"') + + const composedEntry = await readFile(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'), 'utf8') + expect(composedEntry).toContain('src/routes/index.ts') + expect(composedEntry).toContain('src/routes/users/[id].ts') + expect(composedEntry).toContain('createRouteResolve') + expect(composedEntry).toContain('matchFetchRoute') + }) + + test('build preserves an explicit wrangler passthrough main when split worker surfaces exist', async () => { + await writeMultiSurfaceProjectFiles(projectDir, { + passthroughMain: 'src/custom-main.ts' + }) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + + const wranglerConfig = await readGeneratedDevConfig(projectDir) + expect(wranglerConfig).toContain('"main": "../src/custom-main.ts"') + await expect(access(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'))).rejects.toThrow() + }) + + test('build applies rolldown plugins to the bundled worker artifact', async () => { + await writeRolldownWorkerProjectFiles(projectDir) + + const { logger } = createBuildHarness() + await runSuccessfulBuild(projectDir, logger) + const bundledWorker = await readFile(join(projectDir, '.devflare', 'build', 'worker.js'), 'utf8') + expect(bundledWorker).toContain('Hello from Svelte') + }) +}) diff --git a/packages/devflare/tests/integration/cli/build.test.ts b/packages/devflare/tests/integration/cli/build.test.ts new file mode 100644 index 0000000..5ed0cba --- /dev/null +++ b/packages/devflare/tests/integration/cli/build.test.ts @@ -0,0 +1,133 @@ +// ============================================================================= +// CLI Build Command โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + createTestHarness, + createParsedArgs, + createMockProcessRunner, + type TestHarness +} from '../mocks' +import { setDependencies, clearDependencies } from '../../../src/cli/dependencies' + +/** + * Note: Build command requires config loading via c12, which + * reads from the real filesystem. These tests focus on the + * fs/execa interaction patterns after mocking is set up. + * + * For full integration, tests would need real project fixtures + * or c12 mocking. + */ +describe('build command integration', () => { + let harness: TestHarness + + beforeEach(() => { + harness = createTestHarness({ + cwd: '/project', + emptyExeca: true + }) + + // Inject mock dependencies + setDependencies({ + fs: harness.fs.createMock(), + exec: createMockProcessRunner(harness.execa) + }) + }) + + afterEach(() => { + harness.reset() + clearDependencies() + }) + + describe('execa mock verification', () => { + test('mock correctly tracks vite build execution', async () => { + harness.execa.onCommand('vite build', { + exitCode: 0, + stdout: 'Build successful', + stderr: '', + failed: false, + killed: false + }) + + // Simulate what the build command would do + await harness.execa.execa('bunx', ['vite', 'build'], { + cwd: '/project', + stdio: 'inherit' + }) + + expect(harness.execa.wasExecuted('vite build')).toBe(true) + expect(harness.execa.executionCount('vite build')).toBe(1) + }) + + test('mock returns configured result', async () => { + harness.execa.onCommand('vite build', { + exitCode: 0, + stdout: 'Build output here', + stderr: '', + failed: false, + killed: false + }) + + const result = await harness.execa.execa('bunx', ['vite', 'build'], {}) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('Build output here') + }) + + test('mock can simulate build failure', async () => { + harness.execa.onCommand('vite build', { + exitCode: 1, + stdout: '', + stderr: 'Build failed', + failed: true, + killed: false + }) + + const result = await harness.execa.execa('bunx', ['vite', 'build'], { + stdio: 'inherit' + }) + + expect(result.exitCode).toBe(1) + expect(result.failed).toBe(true) + }) + }) + + describe('mock execution ordering', () => { + test('tracks execution order correctly', async () => { + const order: string[] = [] + + harness.execa.on('vite build', () => { + order.push('build') + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + harness.execa.on('wrangler deploy', () => { + order.push('deploy') + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + await harness.execa.execa('bunx', ['vite', 'build'], {}) + await harness.execa.execa('bunx', ['wrangler', 'deploy'], {}) + + expect(order).toEqual(['build', 'deploy']) + }) + }) + + describe('environment variable passing', () => { + test('options are passed to handlers', async () => { + let capturedEnv: Record | undefined + + harness.execa.on('vite build', (cmd, args, options) => { + capturedEnv = options.env as Record + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + await harness.execa.execa('bunx', ['vite', 'build'], { + env: { DEVFLARE_BUILD: 'true' } + }) + + expect(capturedEnv?.DEVFLARE_BUILD).toBe('true') + }) + }) +}) diff --git a/packages/devflare/tests/integration/cli/config-command.test.ts b/packages/devflare/tests/integration/cli/config-command.test.ts new file mode 100644 index 0000000..d856afd --- /dev/null +++ b/packages/devflare/tests/integration/cli/config-command.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { runConfigCommand } from '../../../src/cli/commands/config' +import { createLogger } from '../../helpers/mock-logger' + +describe('runConfigCommand', () => { + let projectDir: string + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-config-command-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'config-command-worker', + compatibilityDate: '2025-01-07', + bindings: { + d1: { + DB: { id: 'existing-d1-id' } + }, + r2: { + ASSETS: 'assets-bucket' + } + } +} + `.trim()) + }) + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }) + }) + + test('prints resolved devflare config JSON', async () => { + const logger = createLogger({ includeLog: false }) + const result = await runConfigCommand( + { command: 'config', args: ['print'], options: { json: true } }, + logger as any, + { cwd: projectDir, silent: true } + ) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('config-command-worker') + expect(result.output).toContain('assets-bucket') + expect(result.output).toContain('existing-d1-id') + }) + + test('prints resolved wrangler config JSON', async () => { + const logger = createLogger({ includeLog: false }) + const result = await runConfigCommand( + { command: 'config', args: ['print'], options: { format: 'wrangler', json: true } }, + logger as any, + { cwd: projectDir, silent: true } + ) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('d1_databases') + expect(result.output).toContain('r2_buckets') + expect(result.output).toContain('existing-d1-id') + }) + + test('prints local wrangler config without Cloudflare account resource resolution', async () => { + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'local-config-worker', + compatibilityDate: '2025-01-07', + bindings: { + d1: { + DB: { name: 'local-database' } + } + } +} + `.trim()) + + const logger = createLogger({ includeLog: false }) + const result = await runConfigCommand( + { command: 'config', args: ['print'], options: { format: 'wrangler', phase: 'local', json: true } }, + logger as any, + { cwd: projectDir, silent: true } + ) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('local-config-worker') + expect(result.output).toContain('d1_databases') + expect(result.output).toContain('local-database') + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-build-provisioning.test.ts b/packages/devflare/tests/integration/cli/deploy-build-provisioning.test.ts new file mode 100644 index 0000000..ded18eb --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-build-provisioning.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { runBuildCommand } from '../../../src/cli/commands/build' +import { runDeployCommand } from '../../../src/cli/commands/deploy' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { + captureDeployEnvironmentSnapshot, + cloudflareApiResponse, + createCliDependencies, + createDeployHarness, + createLogger, + createProcessRunner, + createWranglerDeployProcessRunner, + isViteBuildExecution, + readGeneratedDeployConfig, + successResult, + writeNamedD1ProjectFiles, + type ExecInvocation +} from './build-deploy-worker-only.test-utils' + +function createBuildHarness( + processRunner: Parameters[0] = () => successResult() +): { + executions: ExecInvocation[] + logger: ReturnType +} { + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(processRunner, executions))) + return { + executions, + logger + } +} + +async function runSuccessfulBuild( + projectDir: string, + logger: ReturnType +): Promise { + const result = await runBuildCommand( + { command: 'build', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) +} + +describe('deploy build artifact provisioning', () => { + let projectDir = '' + const originalFetch = globalThis.fetch + + beforeEach(async () => { + clearDependencies() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-deploy-build-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + globalThis.fetch = originalFetch + delete process.env.CLOUDFLARE_API_TOKEN + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('deploy --build provisions named D1 bindings without rebuilding', async () => { + await writeNamedD1ProjectFiles(projectDir) + const buildHarness = createBuildHarness() + await runSuccessfulBuild(projectDir, buildHarness.logger) + + const envSnapshot = captureDeployEnvironmentSnapshot() + const createdRequests: string[] = [] + + try { + process.env.CLOUDFLARE_API_TOKEN = 'test-token' + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const url = String(input) + + if (method === 'GET' && url.includes('/accounts/account-123/d1/database')) { + return cloudflareApiResponse([]) + } + + if (method === 'POST' && url.endsWith('/accounts/account-123/d1/database')) { + createdRequests.push(String(init?.body ?? '')) + return cloudflareApiResponse({ + uuid: 'd1-created', + name: 'app-db', + version: 'alpha', + num_tables: 0, + file_size: 0 + }) + } + + throw new Error(`Unexpected Cloudflare request: ${method} ${url}`) + }) as unknown as typeof fetch + + const { executions, logger } = createDeployHarness(createWranglerDeployProcessRunner({ + structuredOutput: { + version_id: 'version-123', + url: 'https://worker-build-test.example.workers.dev' + } + })) + const result = await runDeployCommand( + { command: 'deploy', args: [], options: { build: '.devflare/build' } }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + expect(executions.some(({ command, args }) => isViteBuildExecution(command, args))).toBe(false) + expect(executions.some(({ command, args }) => command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy')).toBe(true) + expect(createdRequests).toHaveLength(1) + expect(createdRequests[0]).toContain('"name":"app-db"') + + const deployConfig = await readGeneratedDeployConfig(projectDir) + expect(deployConfig).toContain('"database_id": "d1-created"') + expect(deployConfig).not.toContain('"database_name": "app-db"') + } finally { + globalThis.fetch = envSnapshot.fetch + if (typeof envSnapshot.token === 'undefined') { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = envSnapshot.token + } + } + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-env-vars.test.ts b/packages/devflare/tests/integration/cli/deploy-env-vars.test.ts new file mode 100644 index 0000000..b9e847e --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-env-vars.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { runDeployCommand } from '../../../src/cli/commands/deploy' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { + createCliDependencies, + createLogger, + createProcessRunner, + disableCloudflareAccountResolution, + readGeneratedDeployConfig, + successResult, + type ExecInvocation +} from './build-deploy-worker-only.test-utils' + +const DEPLOY_ENV_NAME = 'DEVFLARE_TEST_DEPLOY_GEMINI_API_KEY' + +async function writeEnvDescriptorProject(projectDir: string): Promise { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, '.env'), `${DEPLOY_ENV_NAME}=gemini-from-dotenv\n`) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'worker-deploy-env-test', + private: true, + type: 'module', + devDependencies: { + devflare: '^1.0.0' + } + }, null, '\t')) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'worker-deploy-env-test', + compatibilityDate: '2026-05-01', + files: { + fetch: 'src/fetch.ts' + }, + vars: { + GEMINI_API_KEY: { + __devflareEnvDescriptor: true, + __state: { + name: '${DEPLOY_ENV_NAME}', + optional: false, + hasDefault: false, + hasDevDefault: false + } + } + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), ` +export async function fetch(): Promise { + return new Response('ok') +} +`.trim()) +} + +describe('deploy env var descriptors', () => { + let projectDir = '' + let previousDeployEnvValue: string | undefined + + beforeEach(async () => { + clearDependencies() + disableCloudflareAccountResolution() + previousDeployEnvValue = process.env[DEPLOY_ENV_NAME] + delete process.env[DEPLOY_ENV_NAME] + projectDir = await mkdtemp(join(tmpdir(), 'devflare-deploy-env-vars-')) + }) + + afterEach(async () => { + clearDependencies() + if (previousDeployEnvValue === undefined) { + delete process.env[DEPLOY_ENV_NAME] + } else { + process.env[DEPLOY_ENV_NAME] = previousDeployEnvValue + } + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('deploy --dry-run prints resolved env descriptor values', async () => { + await writeEnvDescriptorProject(projectDir) + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(() => successResult()))) + + const result = await runDeployCommand( + { command: 'deploy', args: [], options: { prod: true, 'dry-run': true } }, + logger as any, + { cwd: projectDir } + ) + const output = logger.messages.map((message) => message.args.join(' ')).join('\n') + + expect(result.exitCode).toBe(0) + expect(output).toContain('gemini-from-dotenv') + expect(output).not.toContain('__devflareEnvDescriptor') + }) + + test('deploy writes resolved env descriptor values to the deploy wrangler config', async () => { + await writeEnvDescriptorProject(projectDir) + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(() => successResult(), executions))) + + const result = await runDeployCommand( + { command: 'deploy', args: [], options: { prod: true } }, + logger as any, + { cwd: projectDir } + ) + const deployConfig = await readGeneratedDeployConfig(projectDir) + + expect(result.exitCode).toBe(0) + expect(executions.some(({ args }) => args.includes('deploy'))).toBe(true) + expect(deployConfig).toContain('gemini-from-dotenv') + expect(deployConfig).not.toContain('__devflareEnvDescriptor') + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-strategy.test.ts b/packages/devflare/tests/integration/cli/deploy-strategy.test.ts new file mode 100644 index 0000000..1088944 --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-strategy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'bun:test' +import type { DevflareConfig } from '../../../src/config' +import { compileConfig } from '../../../src/config/compiler' +import { brandAsLocalConfig } from '../../../src/config/resolve-phased' +import { applyDeploymentStrategy, describeDeploymentStrategy } from '../../../src/cli/deploy-strategy' + +function createQueueAndCronConfig(): DevflareConfig { + return { + name: 'strategy-preview-test', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + bindings: { + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue' + } + ] + } + }, + triggers: { + crons: ['0 * * * *'] + } + } +} + +function createQueueAndCronConfigWithPreviewCrons(): DevflareConfig { + return { + ...createQueueAndCronConfig(), + previews: { + includeCrons: true + } + } +} + +describe('deploy strategy integration', () => { + test('branch-scoped preview deploy strategy omits queue consumers and cron triggers from emitted Wrangler config by default', () => { + const config = createQueueAndCronConfig() + const defaultWranglerConfig = compileConfig(brandAsLocalConfig(config)) + const branchScopedPreview = applyDeploymentStrategy(config, { + environment: 'preview', + previewBranch: 'feature/queue-preview' + }) + const branchPreviewWranglerConfig = compileConfig(brandAsLocalConfig(branchScopedPreview.config)) + + expect(defaultWranglerConfig.queues?.producers).toEqual([ + { binding: 'TASK_QUEUE', queue: 'task-queue' } + ]) + expect(defaultWranglerConfig.queues?.consumers).toEqual([ + { queue: 'task-queue' } + ]) + expect(defaultWranglerConfig.triggers?.crons).toEqual(['0 * * * *']) + + expect(branchScopedPreview.strategy).toBe('preview-scope') + expect(branchScopedPreview.omittedResources).toEqual(['queue-consumers', 'cron-triggers']) + expect(branchPreviewWranglerConfig.queues?.producers).toEqual([ + { binding: 'TASK_QUEUE', queue: 'task-queue' } + ]) + expect(branchPreviewWranglerConfig.queues?.consumers).toBeUndefined() + expect(branchPreviewWranglerConfig.triggers).toBeUndefined() + expect(describeDeploymentStrategy(branchScopedPreview)).toContain('Named preview-scope deploy detected') + }) + + test('branch-scoped preview deploy strategy keeps cron triggers when previews.includeCrons is enabled', () => { + const config = createQueueAndCronConfigWithPreviewCrons() + const branchScopedPreview = applyDeploymentStrategy(config, { + environment: 'preview', + previewBranch: 'feature/cron-preview' + }) + const branchPreviewWranglerConfig = compileConfig(brandAsLocalConfig(branchScopedPreview.config)) + + expect(branchScopedPreview.strategy).toBe('preview-scope') + expect(branchScopedPreview.omittedResources).toEqual(['queue-consumers']) + expect(branchPreviewWranglerConfig.queues?.consumers).toBeUndefined() + expect(branchPreviewWranglerConfig.triggers?.crons).toEqual(['0 * * * *']) + expect(describeDeploymentStrategy(branchScopedPreview)).toContain('queue consumers') + expect(describeDeploymentStrategy(branchScopedPreview)).not.toContain('cron triggers') + }) + + test('deployment strategy keeps same-worker preview uploads unchanged', () => { + const config = createQueueAndCronConfig() + const sameWorkerPreview = applyDeploymentStrategy(config, { + environment: 'preview', + preview: true, + branchName: 'feature/docs' + }) + + expect(sameWorkerPreview.strategy).toBe('default') + expect(sameWorkerPreview.config).toBe(config) + expect(sameWorkerPreview.omittedResources).toEqual([]) + expect(describeDeploymentStrategy(sameWorkerPreview)).toBeUndefined() + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-targets.test.ts b/packages/devflare/tests/integration/cli/deploy-targets.test.ts new file mode 100644 index 0000000..80f1835 --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-targets.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { runDeployCommand } from '../../../src/cli/commands/deploy' +import { createLogger, renderMessages } from '../../helpers/mock-logger' +import { + createCliDependencies, + createProcessRunner, + successResult, + type ExecInvocation +} from './build-deploy-worker-only.test-utils' + +describe('deploy target integration', () => { + let projectDir: string + let originalPreviewBranch: string | undefined + + beforeEach(async () => { + projectDir = mkdtempSync(join(tmpdir(), 'devflare-deploy-targets-')) + originalPreviewBranch = process.env.DEVFLARE_PREVIEW_BRANCH + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'deploy-target-tests', + type: 'module' + }, null, '\t')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'devflare.config.ts'), ` + const branch = process.env.DEVFLARE_PREVIEW_BRANCH?.trim() + export default { + name: branch ? \`demo-worker-\${branch}\` : 'demo-worker', + accountId: 'acc_123', + compatibilityDate: '2026-04-12', + files: { + fetch: 'src/fetch.ts' + } + } + `) + await writeFile(join(projectDir, 'src', 'fetch.ts'), ` + export async function fetch(): Promise { + return new Response('ok') + } + `) + }) + + afterEach(async () => { + if (typeof originalPreviewBranch === 'string') { + process.env.DEVFLARE_PREVIEW_BRANCH = originalPreviewBranch + } else { + delete process.env.DEVFLARE_PREVIEW_BRANCH + } + clearDependencies() + await rm(projectDir, { recursive: true, force: true }) + }) + + test('deploy --prod clears preview branch naming overrides before building', async () => { + process.env.DEVFLARE_PREVIEW_BRANCH = 'next' + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + return successResult('Version ID: version-123') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + prod: true + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(executions.some((execution) => execution.command === 'bunx' && execution.args[0] === 'wrangler' && execution.args[1] === 'deploy')).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker-next'))).toBe(false) + expect(process.env.DEVFLARE_PREVIEW_BRANCH).toBe('next') + }) + + test('deploy requires named preview scope and branch metadata to agree', async () => { + const logger = createLogger() + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: 'next', + 'branch-name': 'feature-branch' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(1) + expect(renderedMessages.some((message) => message.includes('Named preview deploys use the --preview value as the preview scope'))).toBe(true) + }) + + test('deploy --preview deploys a named preview scope with branch-scoped worker naming', async () => { + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + return successResult('Version ID: version-456') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: 'next' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(executions.some((execution) => execution.command === 'bunx' && execution.args[0] === 'wrangler' && execution.args[1] === 'deploy')).toBe(true) + expect(executions.some((execution) => execution.command === 'bunx' && execution.args[0] === 'wrangler' && execution.args[1] === 'versions' && execution.args[2] === 'upload')).toBe(false) + expect(renderedMessages.some((message) => message.includes('demo-worker-next'))).toBe(true) + expect(process.env.DEVFLARE_PREVIEW_BRANCH).toBe(originalPreviewBranch) + }) + + test('deploy --preview clears stale preview branch naming overrides before same-worker preview uploads', async () => { + process.env.DEVFLARE_PREVIEW_BRANCH = 'next' + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'versions' && args[2] === 'upload') { + return successResult('Version ID: version-789') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: true + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(executions.some((execution) => ( + execution.command === 'bunx' + && execution.args[0] === 'wrangler' + && execution.args[1] === 'versions' + && execution.args[2] === 'upload' + ))).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker-next'))).toBe(false) + expect(process.env.DEVFLARE_PREVIEW_BRANCH).toBe('next') + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-worker-only-preview.test.ts b/packages/devflare/tests/integration/cli/deploy-worker-only-preview.test.ts new file mode 100644 index 0000000..a6740b0 --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-worker-only-preview.test.ts @@ -0,0 +1,534 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { runDeployCommand } from '../../../src/cli/commands/deploy' +import { + TEST_ACCOUNT_ID, + captureDeployEnvironmentSnapshot, + cloudflareApiResponse, + createCliDependencies, + createLogger, + createProcessRunner, + createWorkerVersionDetail, + disableCloudflareAccountResolution, + enableStrictDeployVerification, + isViteBuildExecution, + restoreDeployEnvironmentSnapshot, + successResult, + writeAccountProjectFiles, + writeProjectFiles, + type ExecInvocation +} from './build-deploy-worker-only.test-utils' + +const originalEnvironment = captureDeployEnvironmentSnapshot() + +describe('build/deploy worker-only behavior', () => { + let projectDir = '' + + beforeEach(async () => { + clearDependencies() + disableCloudflareAccountResolution() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-deploy-worker-preview-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + restoreDeployEnvironmentSnapshot(originalEnvironment) + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('deploy skips vite for worker-only projects and still runs wrangler deploy', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (isViteBuildExecution(command, args)) { + throw new Error('vite build should not run for worker-only deploy') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { command: 'deploy', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + expect(executions.some(({ command, args }) => isViteBuildExecution(command, args))).toBe(false) + expect(executions.some(({ command, args }) => command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy')).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Skipping Vite build'))).toBe(true) + await access(join(projectDir, '.wrangler', 'deploy', 'config.json')) + }) + + test('deploy runs the local wrangler package with node when it is installed in the project', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + await mkdir(join(projectDir, 'node_modules', 'wrangler', 'bin'), { recursive: true }) + await writeFile(join(projectDir, 'node_modules', 'wrangler', 'package.json'), JSON.stringify({ + name: 'wrangler', + version: '3.114.17', + type: 'module', + bin: { + wrangler: './bin/wrangler.js' + } + }, null, '\t')) + await writeFile(join(projectDir, 'node_modules', 'wrangler', 'bin', 'wrangler.js'), ` +#!/usr/bin/env node +console.log('stub wrangler binary') +`.trim()) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (isViteBuildExecution(command, args)) { + throw new Error('vite build should not run for worker-only deploy') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { command: 'deploy', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + const deployExecution = executions.find(({ args }) => args.includes('deploy')) + expect(deployExecution?.command).toBe('node') + expect(deployExecution?.args[0]?.replace(/\\/g, '/')).toBe(`${projectDir.replace(/\\/g, '/')}/node_modules/wrangler/bin/wrangler.js`) + expect(deployExecution?.args).toContain('deploy') + expect(deployExecution?.args).toContain('--config') + }) + + test('deploy runs a local wrangler package from an ancestor workspace directory with node', async () => { + const workspaceDir = join(projectDir, 'workspace') + const workerDir = join(workspaceDir, 'workers', 'auth-service') + await mkdir(workerDir, { recursive: true }) + await writeProjectFiles(workerDir, { withViteConfig: false, withViteDeps: false }) + await mkdir(join(workspaceDir, 'node_modules', 'wrangler', 'bin'), { recursive: true }) + await writeFile(join(workspaceDir, 'node_modules', 'wrangler', 'package.json'), JSON.stringify({ + name: 'wrangler', + version: '4.81.1', + type: 'module', + bin: { + wrangler: './bin/wrangler.js' + } + }, null, '\t')) + await writeFile(join(workspaceDir, 'node_modules', 'wrangler', 'bin', 'wrangler.js'), ` +#!/usr/bin/env node +console.log('stub wrangler binary') +`.trim()) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (isViteBuildExecution(command, args)) { + throw new Error('vite build should not run for worker-only deploy') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { command: 'deploy', args: [], options: {} }, + logger as any, + { cwd: workerDir } + ) + + expect(result.exitCode).toBe(0) + const deployExecution = executions.find(({ args }) => args.includes('deploy')) + expect(deployExecution?.command).toBe('node') + expect(deployExecution?.args[0]?.replace(/\\/g, '/')).toBe(`${workspaceDir.replace(/\\/g, '/')}/node_modules/wrangler/bin/wrangler.js`) + expect(deployExecution?.args).toContain('deploy') + expect(deployExecution?.args).toContain('--config') + }) + + test('deploy forwards Wrangler version metadata flags when message and tag are provided', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies(createProcessRunner(() => successResult(), executions))) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + message: 'Documentation production run', + tag: 'documentation-production-123' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + const deployExecution = executions.find(({ command, args }) => command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') + expect(deployExecution?.args).toContain('--message') + expect(deployExecution?.args).toContain('Documentation production run') + expect(deployExecution?.args).toContain('--tag') + expect(deployExecution?.args).toContain('documentation-production-123') + }) + + test('deploy uploads a preview version and surfaces preview metadata', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'versions' && args[2] === 'upload') { + return successResult('Version ID: version-123\nPreview URL: https://preview.example.workers.dev') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: true, + 'branch-name': 'feature/branch' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + const previewExecution = executions.find(({ command, args }) => command === 'bunx' && args[0] === 'wrangler' && args[1] === 'versions' && args[2] === 'upload') + expect(previewExecution?.args).toContain('versions') + expect(previewExecution?.args).toContain('upload') + expect(previewExecution?.args).toContain('--config') + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-123'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Preview URL: https://preview.example.workers.dev'))).toBe(true) + }) + + test('deploy writes canonical metadata when DEVFLARE_DEPLOY_METADATA_PATH is configured', async () => { + await writeProjectFiles(projectDir, { withViteConfig: false, withViteDeps: false }) + process.env.DEVFLARE_DEPLOY_METADATA_PATH = join(projectDir, 'deploy-result.json') + + const executions: ExecInvocation[] = [] + const logger = createLogger() + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'versions' && args[2] === 'upload') { + return successResult('Version ID: version-123\nPreview URL: https://preview.example.workers.dev') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: true, + 'branch-name': 'feature/branch' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + const metadata = JSON.parse(await readFile(join(projectDir, 'deploy-result.json'), 'utf8')) as { + status: string + exitCode: number + workerName: string + preview: boolean + versionId?: string + previewUrl?: string + outputUrls: string[] + } + + expect(metadata.status).toBe('success') + expect(metadata.exitCode).toBe(0) + expect(metadata.workerName).toBe('worker-build-test') + expect(metadata.preview).toBe(true) + expect(metadata.versionId).toBe('version-123') + expect(metadata.previewUrl).toBe('https://preview.example.workers.dev') + expect(metadata.outputUrls).toContain('https://preview.example.workers.dev') + }) + + test('deploy verifies preview uploads in Cloudflare control plane when strict verification is enabled', async () => { + await writeAccountProjectFiles(projectDir) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions/version-123')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-123', { hasPreview: true })) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification() + + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'versions' && args[2] === 'upload') { + return successResult('Version ID: version-123\nPreview URL: https://preview.example.workers.dev') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: true, + 'branch-name': 'feature/branch' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified preview upload in Cloudflare control plane for version version-123'))).toBe(true) + }) + + test('deploy derives branch-scoped preview urls from the workers.dev subdomain when wrangler omits them', async () => { + await writeAccountProjectFiles(projectDir, { + accountId: TEST_ACCOUNT_ID, + workerName: 'worker-build-test-next' + }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + + globalThis.fetch = mock(async () => new Response(JSON.stringify({ + success: true, + result: { subdomain: 'example-subdomain' }, + errors: [], + messages: [] + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + })) as unknown as typeof fetch + process.env.CLOUDFLARE_API_TOKEN = 'test-token' + + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + return successResult('Version ID: version-456') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: 'next' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Preview URL: https://worker-build-test-next.example-subdomain.workers.dev'))).toBe(true) + }) + + test('deploy resolves branch-scoped preview version ids from Cloudflare when Wrangler omits them', async () => { + await writeAccountProjectFiles(projectDir, { + accountId: TEST_ACCOUNT_ID, + workerName: 'worker-build-test-next' + }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + const requestedUrls: string[] = [] + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + requestedUrls.push(url) + + if (url.endsWith(`/accounts/${TEST_ACCOUNT_ID}/workers/subdomain`)) { + return cloudflareApiResponse({ subdomain: 'example-subdomain' }) + } + + if (url.includes('/workers/scripts/worker-build-test-next/versions?page=1&per_page=100')) { + return cloudflareApiResponse({ + items: [ + createWorkerVersionDetail('version-from-list', { + hasPreview: true, + createdOn: new Date().toISOString(), + modifiedOn: new Date().toISOString() + }) + ] + }) + } + + if (url.includes('/workers/scripts/worker-build-test-next/versions/version-from-list')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-from-list')) + } + + if (url.endsWith('/workers/scripts/worker-build-test-next/deployments')) { + return cloudflareApiResponse({ + deployments: [ + { + id: 'deployment-from-list', + created_on: new Date().toISOString(), + source: 'wrangler', + strategy: 'percentage', + author_email: 'test@example.com', + versions: [{ + percentage: 100, + version_id: 'version-from-list' + }] + } + ] + }) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification({ accountId: TEST_ACCOUNT_ID }) + + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + return successResult('Deployed successfully') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: 'next' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Preview URL: https://worker-build-test-next.example-subdomain.workers.dev'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-from-list'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Resolved version id from Cloudflare version metadata'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-from-list for version version-from-list'))).toBe(true) + expect(requestedUrls).toContain( + `https://api.cloudflare.com/client/v4/accounts/${TEST_ACCOUNT_ID}/workers/subdomain` + ) + expect(requestedUrls.some((url) => url.includes('/workers/scripts/worker-build-test-next/versions?page=1&per_page=100'))).toBe(true) + expect(requestedUrls.some((url) => url.endsWith('/workers/scripts/worker-build-test-next/deployments'))).toBe(true) + }) + + test('deploy fails strict branch-scoped preview deploys when Cloudflare cannot expose a fresh version id', async () => { + await writeAccountProjectFiles(projectDir, { + accountId: TEST_ACCOUNT_ID, + workerName: 'worker-build-test-next' + }) + + const executions: ExecInvocation[] = [] + const logger = createLogger() + const requestedUrls: string[] = [] + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + requestedUrls.push(url) + + if (url.endsWith(`/accounts/${TEST_ACCOUNT_ID}/workers/subdomain`)) { + return cloudflareApiResponse({ subdomain: 'example-subdomain' }) + } + + if (url.includes('/workers/scripts/worker-build-test-next/versions?page=1&per_page=100')) { + return cloudflareApiResponse({ + items: [ + createWorkerVersionDetail('stale-version', { + hasPreview: true, + createdOn: '2026-01-01T00:00:00.000Z', + modifiedOn: '2026-01-01T00:00:00.000Z' + }) + ] + }) + } + + if (url.endsWith('/workers/scripts/worker-build-test-next/deployments')) { + return cloudflareApiResponse({ + deployments: [ + { + id: 'stale-deployment', + created_on: '2026-01-01T00:00:00.000Z', + source: 'wrangler', + strategy: 'percentage', + author_email: 'test@example.com', + versions: [{ + percentage: 100, + version_id: 'stale-version' + }] + } + ] + }) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification({ accountId: TEST_ACCOUNT_ID, delayMs: '0' }) + + setDependencies(createCliDependencies( + createProcessRunner((command, args) => { + if (command === 'bunx' && args[0] === 'wrangler' && args[1] === 'deploy') { + return successResult('Deployed successfully') + } + + return successResult() + }, executions) + )) + + const result = await runDeployCommand( + { + command: 'deploy', + args: [], + options: { + preview: 'next' + } + }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.args.join(' ').includes('Preview URL: https://worker-build-test-next.example-subdomain.workers.dev'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Deployment verification failed: Wrangler did not return a Worker version id'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('preview-scope deploy as successful'))).toBe(false) + expect(requestedUrls.some((url) => url.includes('/workers/scripts/worker-build-test-next/versions?page=1&per_page=100'))).toBe(true) + expect(requestedUrls.some((url) => url.endsWith('/workers/scripts/worker-build-test-next/deployments'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-worker-only-production-edge-cases.test.ts b/packages/devflare/tests/integration/cli/deploy-worker-only-production-edge-cases.test.ts new file mode 100644 index 0000000..029dfe1 --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-worker-only-production-edge-cases.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { clearDependencies } from '../../../src/cli/dependencies' +import { + captureDeployEnvironmentSnapshot, + cloudflareApiResponse, + createDeployHarness, + createWorkerDeployment, + createWorkerDeploymentsList, + createWorkerVersionDetail, + createWorkerVersionsList, + disableCloudflareAccountResolution, + enableStrictDeployVerification, + restoreDeployEnvironmentSnapshot, + runWorkerOnlyDeploy, + writeAccountProjectFiles, +} from './build-deploy-worker-only.test-utils' + +const originalEnvironment = captureDeployEnvironmentSnapshot() + +function mockExistingLiveProductionState(): void { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions?page=1&per_page=100')) { + return createWorkerVersionsList([ + createWorkerVersionDetail('version-existing', { + createdOn: '2020-01-01T00:00:00.000Z', + modifiedOn: '2020-01-01T00:00:00.000Z' + }) + ]) + } + + if (url.includes('/workers/scripts/worker-build-test/versions/version-existing')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-existing')) + } + + if (url.endsWith('/workers/scripts/worker-build-test/deployments')) { + return createWorkerDeploymentsList([ + createWorkerDeployment('deployment-existing', 'version-existing', { + createdOn: '2020-01-01T00:00:00.000Z', + authorEmail: 'test@example.com' + }) + ]) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch +} + +describe('build/deploy worker-only behavior', () => { + let projectDir = '' + + beforeEach(async () => { + clearDependencies() + disableCloudflareAccountResolution() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-deploy-worker-production-edge-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + restoreDeployEnvironmentSnapshot(originalEnvironment) + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('deploy accepts the current active production deployment when Cloudflare only exposes older live state', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness() + mockExistingLiveProductionState() + enableStrictDeployVerification() + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-existing'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Deployment verification note:'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Cloudflare kept the existing live version'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-existing for version version-existing'))).toBe(true) + }) + + test('deploy fails when a fresh production deployment is required but Cloudflare only exposes the current live deployment', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness() + mockExistingLiveProductionState() + enableStrictDeployVerification({ requireFreshProductionDeployment: true }) + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.args.join(' ').includes('requires a fresh production deployment'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('reused live version as a failure'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/integration/cli/deploy-worker-only-production-verification.test.ts b/packages/devflare/tests/integration/cli/deploy-worker-only-production-verification.test.ts new file mode 100644 index 0000000..fa5e6da --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy-worker-only-production-verification.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { clearDependencies } from '../../../src/cli/dependencies' +import { + captureDeployEnvironmentSnapshot, + cloudflareApiResponse, + createDeployHarness, + createWorkerDeployment, + createWorkerDeploymentsList, + createWorkerVersionDetail, + createWorkerVersionsList, + createWranglerDeployProcessRunner, + disableCloudflareAccountResolution, + enableStrictDeployVerification, + restoreDeployEnvironmentSnapshot, + runWorkerOnlyDeploy, + successResult, + writeAccountProjectFiles, +} from './build-deploy-worker-only.test-utils' + +const originalEnvironment = captureDeployEnvironmentSnapshot() + +describe('build/deploy worker-only behavior', () => { + let projectDir = '' + + beforeEach(async () => { + clearDependencies() + disableCloudflareAccountResolution() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-deploy-worker-production-')) + await mkdir(join(projectDir, 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + restoreDeployEnvironmentSnapshot(originalEnvironment) + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('deploy verifies production deployments reference the uploaded version when strict verification is enabled', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness( + createWranglerDeployProcessRunner({ + stdout: 'Version ID: version-123' + }) + ) + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions/version-123')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-123')) + } + + if (url.endsWith('/workers/scripts/worker-build-test/deployments')) { + return createWorkerDeploymentsList([ + createWorkerDeployment('deployment-123', 'version-123', { + createdOn: '2026-04-09T00:00:00Z', + authorEmail: 'test@example.com' + }) + ]) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification() + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-123 for version version-123'))).toBe(true) + }) + + test('deploy verifies production deployments when Wrangler only reports the version id through structured output', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness( + createWranglerDeployProcessRunner({ + structuredOutput: { + type: 'deploy', + version_id: 'version-structured', + targets: ['https://worker-build-test.example.workers.dev'] + } + }) + ) + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions/version-structured')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-structured')) + } + + if (url.endsWith('/workers/scripts/worker-build-test/deployments')) { + return createWorkerDeploymentsList([ + createWorkerDeployment('deployment-structured', 'version-structured', { + createdOn: '2026-04-09T00:00:00Z', + authorEmail: 'test@example.com' + }) + ]) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification() + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-structured'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-structured for version version-structured'))).toBe(true) + }) + + test('deploy falls back to the latest Cloudflare version when Wrangler omits the production version id', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness() + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions?page=1&per_page=100')) { + return createWorkerVersionsList([ + createWorkerVersionDetail('version-from-list', { + createdOn: new Date().toISOString(), + modifiedOn: new Date().toISOString() + }) + ]) + } + + if (url.includes('/workers/scripts/worker-build-test/versions/version-from-list')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-from-list')) + } + + if (url.endsWith('/workers/scripts/worker-build-test/deployments')) { + return createWorkerDeploymentsList([ + createWorkerDeployment('deployment-from-list', 'version-from-list', { + createdOn: new Date().toISOString(), + authorEmail: 'test@example.com' + }) + ]) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification() + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-from-list'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Resolved version id from Cloudflare version metadata'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-from-list for version version-from-list'))).toBe(true) + }) + + test('deploy falls back to the latest Cloudflare deployment when Wrangler omits the production version id entirely', async () => { + await writeAccountProjectFiles(projectDir) + + const { logger } = createDeployHarness() + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/workers/scripts/worker-build-test/versions/version-from-deployment')) { + return cloudflareApiResponse(createWorkerVersionDetail('version-from-deployment')) + } + + if (url.endsWith('/workers/scripts/worker-build-test/deployments')) { + return createWorkerDeploymentsList([ + createWorkerDeployment('deployment-fallback', 'version-from-deployment', { + createdOn: new Date().toISOString(), + authorEmail: 'test@example.com' + }) + ]) + } + + throw new Error(`Unexpected Cloudflare request: ${url}`) + }) as unknown as typeof fetch + enableStrictDeployVerification() + + const result = await runWorkerOnlyDeploy(projectDir, logger) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Version ID: version-from-deployment'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Verified Cloudflare deployment deployment-fallback for version version-from-deployment'))).toBe(true) + }, 20000) +}) diff --git a/packages/devflare/tests/integration/cli/deploy.test.ts b/packages/devflare/tests/integration/cli/deploy.test.ts new file mode 100644 index 0000000..f9fbc9c --- /dev/null +++ b/packages/devflare/tests/integration/cli/deploy.test.ts @@ -0,0 +1,140 @@ +// ============================================================================= +// CLI Deploy Command โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + createTestHarness, + createParsedArgs, + createMockProcessRunner, + type TestHarness +} from '../mocks' +import { setDependencies, clearDependencies } from '../../../src/cli/dependencies' + +/** + * Note: Deploy command requires config loading via c12. + * These tests verify the mock infrastructure works correctly + * for execa subprocess simulation. + */ +describe('deploy command integration', () => { + let harness: TestHarness + + beforeEach(() => { + harness = createTestHarness({ + cwd: '/project', + emptyExeca: true + }) + + // Inject mock dependencies + setDependencies({ + fs: harness.fs.createMock(), + exec: createMockProcessRunner(harness.execa) + }) + }) + + afterEach(() => { + harness.reset() + clearDependencies() + }) + + describe('execa mock - deploy workflow simulation', () => { + test('simulates build then deploy sequence', async () => { + const order: string[] = [] + + harness.execa.on('vite build', () => { + order.push('build') + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + harness.execa.on('wrangler deploy', () => { + order.push('deploy') + return { exitCode: 0, stdout: 'Deployed!', stderr: '', failed: false, killed: false } + }) + + // Simulate deploy workflow + await harness.execa.execa('bunx', ['vite', 'build'], { cwd: '/project' }) + await harness.execa.execa('bunx', ['wrangler', 'deploy'], { cwd: '/project' }) + + expect(order).toEqual(['build', 'deploy']) + expect(harness.execa.wasExecuted('vite build')).toBe(true) + expect(harness.execa.wasExecuted('wrangler deploy')).toBe(true) + }) + + test('stops at build failure', async () => { + const order: string[] = [] + + harness.execa.on('vite build', () => { + order.push('build') + return { exitCode: 1, stdout: '', stderr: 'Build failed', failed: true, killed: false } + }) + + harness.execa.on('wrangler deploy', () => { + order.push('deploy') + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + // Simulate: build fails, so don't deploy + const buildResult = await harness.execa.execa('bunx', ['vite', 'build'], { + stdio: 'inherit' + }) + + if (buildResult.exitCode !== 0) { + // Don't deploy + } else { + await harness.execa.execa('bunx', ['wrangler', 'deploy'], {}) + } + + expect(order).toEqual(['build']) + expect(harness.execa.wasExecuted('wrangler deploy')).toBe(false) + }) + }) + + describe('environment handling', () => { + test('passes environment to wrangler', async () => { + let capturedArgs: string[] = [] + + harness.execa.on('wrangler', (cmd, args) => { + capturedArgs = args + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + await harness.execa.execa('bunx', ['wrangler', 'deploy', '--env', 'production'], {}) + + expect(capturedArgs).toContain('--env') + expect(capturedArgs).toContain('production') + }) + }) + + describe('execution tracking', () => { + test('counts executions correctly', async () => { + harness.execa.onCommand('vite', { + exitCode: 0, + stdout: '', + stderr: '', + failed: false, + killed: false + }) + + await harness.execa.execa('bunx', ['vite', 'build'], {}) + await harness.execa.execa('bunx', ['vite', 'build'], {}) + + expect(harness.execa.executionCount('vite')).toBe(2) + }) + + test('clears executions on reset', async () => { + harness.execa.onCommand('test', { + exitCode: 0, + stdout: '', + stderr: '', + failed: false, + killed: false + }) + + await harness.execa.execa('test', [], {}) + expect(harness.execa.executions.length).toBe(1) + + harness.execa.clearExecutions() + expect(harness.execa.executions.length).toBe(0) + }) + }) +}) diff --git a/packages/devflare/tests/integration/cli/dev.test.ts b/packages/devflare/tests/integration/cli/dev.test.ts new file mode 100644 index 0000000..2146bc2 --- /dev/null +++ b/packages/devflare/tests/integration/cli/dev.test.ts @@ -0,0 +1,118 @@ +// ============================================================================= +// CLI Dev Command โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + createTestHarness, + createMockProcessRunner, + type TestHarness +} from '../mocks' +import { setDependencies, clearDependencies } from '../../../src/cli/dependencies' + +/** + * Note: Dev command requires config loading via c12. + * These tests verify the mock infrastructure for dev server simulation. + */ +describe('dev command integration', () => { + let harness: TestHarness + + beforeEach(() => { + harness = createTestHarness({ + cwd: '/project', + emptyExeca: true + }) + + // Inject mock dependencies + setDependencies({ + fs: harness.fs.createMock(), + exec: createMockProcessRunner(harness.execa) + }) + }) + + afterEach(() => { + harness.reset() + clearDependencies() + }) + + describe('execa mock - dev server simulation', () => { + test('simulates vite dev command', async () => { + harness.execa.onCommand('vite dev', { + exitCode: 0, + stdout: 'Dev server at http://localhost:5173', + stderr: '', + failed: false, + killed: false + }) + + const result = await harness.execa.execa('bunx', ['vite', 'dev'], { + cwd: '/project', + stdio: 'inherit' + }) + + expect(result.exitCode).toBe(0) + expect(harness.execa.wasExecuted('vite dev')).toBe(true) + }) + + test('passes port option correctly', async () => { + let capturedArgs: string[] = [] + + harness.execa.on('vite', (cmd, args) => { + capturedArgs = args + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + await harness.execa.execa('bunx', ['vite', 'dev', '--port', '3000'], {}) + + expect(capturedArgs).toContain('--port') + expect(capturedArgs).toContain('3000') + }) + + test('captures environment variables', async () => { + let capturedEnv: Record | undefined + + harness.execa.on('vite', (cmd, args, options) => { + capturedEnv = options.env as Record + return { exitCode: 0, stdout: '', stderr: '', failed: false, killed: false } + }) + + await harness.execa.execa('bunx', ['vite', 'dev'], { + env: { DEVFLARE_DEV: 'true', NODE_ENV: 'development' } + }) + + expect(capturedEnv?.DEVFLARE_DEV).toBe('true') + expect(capturedEnv?.NODE_ENV).toBe('development') + }) + }) + + describe('error simulation', () => { + test('simulates dev server crash', async () => { + harness.execa.onCommand('vite dev', { + exitCode: 1, + stdout: '', + stderr: 'EADDRINUSE: port 5173 is already in use', + failed: true, + killed: false + }) + + const result = await harness.execa.execa('bunx', ['vite', 'dev'], { + stdio: 'inherit' + }) + + expect(result.exitCode).toBe(1) + expect(result.failed).toBe(true) + }) + }) + + describe('regex matching', () => { + test('matches with regex pattern', async () => { + harness.execa.on(/vite.*dev/, () => { + return { exitCode: 0, stdout: 'matched', stderr: '', failed: false, killed: false } + }) + + const result = await harness.execa.execa('bunx', ['vite', 'dev', '--host'], {}) + + expect(result.stdout).toBe('matched') + }) + }) +}) diff --git a/packages/devflare/tests/integration/cli/doctor-command.test.ts b/packages/devflare/tests/integration/cli/doctor-command.test.ts new file mode 100644 index 0000000..67acd78 --- /dev/null +++ b/packages/devflare/tests/integration/cli/doctor-command.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { clearDependencies } from '../../../src/cli/dependencies' +import { runDoctorCommand } from '../../../src/cli/commands/doctor' +import { createLogger, renderMessages } from '../../helpers/mock-logger' + +async function writeProjectFiles( + projectDir: string, + packageJson: Record, + withViteConfig = false, + configFileName = 'devflare.config.ts' +): Promise { + await mkdir(join(projectDir, 'src'), { recursive: true }) + + await writeFile(join(projectDir, 'package.json'), JSON.stringify(packageJson, null, 2)) + await writeFile(join(projectDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext' + } + }, null, 2)) + await writeFile(join(projectDir, configFileName), ` + export default { + name: 'test-worker' + } + `.trim()) + + if (withViteConfig) { + await writeFile(join(projectDir, 'vite.config.ts'), ` + import { defineConfig } from 'vite' + + export default defineConfig({}) + `.trim()) + } +} + +describe('runDoctorCommand', () => { + let projectDir: string + + beforeEach(async () => { + clearDependencies() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-doctor-')) + }) + + afterEach(async () => { + clearDependencies() + await rm(projectDir, { recursive: true, force: true }) + }) + + test('does not warn about missing Vite config for worker-only projects', async () => { + await writeProjectFiles(projectDir, { + name: 'worker-only', + private: true, + devDependencies: { + devflare: '^1.0.0' + } + }) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('No vite.config found'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('vite required but not found'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('@cloudflare/vite-plugin required but not found'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('worker-only mode'))).toBe(true) + }) + + test('warns about missing vite.config only when the current package opted into Vite integration', async () => { + await writeProjectFiles(projectDir, { + name: 'vite-project', + private: true, + devDependencies: { + devflare: '^1.0.0', + vite: '^6.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + }) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('No vite.config found'))).toBe(true) + }) + + test('describes @cloudflare/vite-plugin as optional when a Vite package omits it', async () => { + await writeProjectFiles(projectDir, { + name: 'sveltekit-project', + private: true, + devDependencies: { + devflare: '^1.0.0', + vite: '^6.0.0' + } + }, true) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => + message.includes('@cloudflare/vite-plugin') && message.includes('Optional') + )).toBe(true) + }) + + test('reports package.json and resolved devflare versions', async () => { + await writeProjectFiles(projectDir, { + name: 'version-project', + private: true, + devDependencies: { + devflare: 'next' + } + }) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => + message.includes('package.json: next') && message.includes('resolved:') + )).toBe(true) + }) + + test('scope local omits deploy artifact readiness warnings', async () => { + await writeProjectFiles(projectDir, { + name: 'local-project', + private: true, + devDependencies: { + devflare: '^1.0.0' + } + }) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: { scope: 'local' } }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('Generated deploy config'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('Wrangler deploy redirect'))).toBe(false) + }) + + test('accepts .devflare/wrangler.jsonc as generated config output', async () => { + await writeProjectFiles(projectDir, { + name: 'vite-project', + private: true, + devDependencies: { + devflare: '^1.0.0' + } + }) + await mkdir(join(projectDir, '.devflare'), { recursive: true }) + await writeFile(join(projectDir, '.devflare', 'wrangler.jsonc'), '{"name":"test-worker"}') + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('.devflare/wrangler.jsonc'))).toBe(true) + }) + + test('supports --config with alternate supported config filenames', async () => { + await writeProjectFiles(projectDir, { + name: 'mts-project', + private: true, + devDependencies: { + devflare: '^1.0.0' + } + }, false, 'devflare.config.mts') + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: { config: 'devflare.config.mts' } }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('devflare.config.mts'))).toBe(true) + }) + + test('lists all supported config filenames when none are found', async () => { + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'missing-config-project', + private: true, + devDependencies: { + devflare: '^1.0.0' + } + }, null, 2)) + + const logger = createLogger() + const result = await runDoctorCommand( + { command: 'doctor', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(1) + expect(renderedMessages.some((message) => message.includes('devflare.config.ts, devflare.config.mts, devflare.config.js, devflare.config.mjs'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/integration/cli/init.test.ts b/packages/devflare/tests/integration/cli/init.test.ts new file mode 100644 index 0000000..a5e82a9 --- /dev/null +++ b/packages/devflare/tests/integration/cli/init.test.ts @@ -0,0 +1,296 @@ +// ============================================================================= +// CLI Init Command โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + createTestHarness, + createParsedArgs, + createMockProcessRunner, + type TestHarness +} from '../mocks' +import { runInitCommand } from '../../../src/cli/commands/init' +import { setDependencies, clearDependencies } from '../../../src/cli/dependencies' +import { getInitDependencyVersions } from '../../../src/cli/package-metadata' + +describe('init command integration', () => { + let harness: TestHarness + + beforeEach(() => { + harness = createTestHarness({ + cwd: '/workspace' + }) + + // Inject mock dependencies + setDependencies({ + fs: harness.fs.createMock(), + exec: createMockProcessRunner(harness.execa) + }) + }) + + afterEach(() => { + harness.reset() + clearDependencies() + }) + + describe('project creation', () => { + test('creates project directory with minimal template', async () => { + const parsed = createParsedArgs('init', ['my-project'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + // Verify directory was created + expect(harness.fs.exists('/workspace/my-project')).toBe(true) + + // Verify expected files exist + expect(harness.fs.exists('/workspace/my-project/package.json')).toBe(true) + expect(harness.fs.exists('/workspace/my-project/devflare.config.ts')).toBe(true) + expect(harness.fs.exists('/workspace/my-project/src/fetch.ts')).toBe(true) + expect(harness.fs.exists('/workspace/my-project/tsconfig.json')).toBe(true) + + // Check success message was logged + expect(harness.logger.success).toHaveBeenCalled() + }) + + test('creates all required files for minimal template', async () => { + const parsed = createParsedArgs('init', ['test-app'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + // Check all files from minimal template + const projectPath = '/workspace/test-app' + expect(harness.fs.exists(`${projectPath}/devflare.config.ts`)).toBe(true) + expect(harness.fs.exists(`${projectPath}/src/fetch.ts`)).toBe(true) + expect(harness.fs.exists(`${projectPath}/package.json`)).toBe(true) + expect(harness.fs.exists(`${projectPath}/tsconfig.json`)).toBe(true) + }) + + test('creates project with api template', async () => { + const parsed = createParsedArgs('init', ['api-app'], { + template: 'api' + }) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + // Check API-specific files + const projectPath = '/workspace/api-app' + expect(harness.fs.exists(`${projectPath}/src/fetch.ts`)).toBe(true) + expect(harness.fs.exists(`${projectPath}/src/app.ts`)).toBe(true) + expect(harness.fs.exists(`${projectPath}/src/middleware/cors.ts`)).toBe(true) + }) + + test('fails for unknown template', async () => { + const parsed = createParsedArgs('init', ['my-app'], { + template: 'nonexistent' + }) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(1) + expect(harness.logger.error).toHaveBeenCalled() + }) + + test('uses default project name when not provided', async () => { + const parsed = createParsedArgs('init', [], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + expect(harness.fs.exists('/workspace/my-devflare-app')).toBe(true) + }) + }) + + describe('file content validation', () => { + test('generates package.json with correct project name', async () => { + const dependencyVersions = await getInitDependencyVersions() + const parsed = createParsedArgs('init', ['custom-name'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + const packageJson = harness.fs.getContent('/workspace/custom-name/package.json') + expect(packageJson).not.toBeNull() + + const pkg = JSON.parse(packageJson!) + expect(pkg.name).toBe('custom-name') + expect(pkg.devDependencies.devflare).toBe(dependencyVersions.devflare) + expect(pkg.devDependencies.wrangler).toBe(dependencyVersions.wrangler) + expect(pkg.devDependencies['@cloudflare/workers-types']).toBe(dependencyVersions.workersTypes) + }) + + test('generates devflare.config.ts with project name', async () => { + const parsed = createParsedArgs('init', ['my-worker'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + const configContent = harness.fs.getContent('/workspace/my-worker/devflare.config.ts') + expect(configContent).not.toBeNull() + expect(configContent).toContain("name: 'my-worker'") + expect(configContent).toContain("fetch: 'src/fetch.ts'") + }) + + test('generates src/fetch.ts with named fetch export and README-aligned response text', async () => { + const parsed = createParsedArgs('init', ['test-app'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + const fetchContent = harness.fs.getContent('/workspace/test-app/src/fetch.ts') + expect(fetchContent).not.toBeNull() + expect(fetchContent).toContain('export async function fetch') + expect(fetchContent).toContain('FetchEvent') + expect(fetchContent).toContain('fetch({ url }: FetchEvent)') + expect(fetchContent).toContain("url.pathname === '/'") + expect(fetchContent).toContain('Hello from Devflare') + expect(fetchContent).toContain('Hello from Devflare:') + expect(fetchContent).not.toContain('export default') + }) + + test('api template uses src/fetch.ts with a single named handle export', async () => { + const parsed = createParsedArgs('init', ['api-app'], { + template: 'api' + }) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + const fetchContent = harness.fs.getContent('/workspace/api-app/src/fetch.ts') + expect(fetchContent).not.toBeNull() + expect(fetchContent).toContain("import { sequence } from 'devflare/runtime'") + expect(fetchContent).toContain('export const handle = sequence(corsHandle, appFetch)') + expect(fetchContent).not.toContain('export const fetch = sequence(') + expect(fetchContent).not.toContain('export default') + + const appContent = harness.fs.getContent('/workspace/api-app/src/app.ts') + expect(appContent).not.toBeNull() + expect(appContent).toContain('export async function appFetch') + expect(appContent).toContain('appFetch({ url }: FetchEvent)') + expect(appContent).toContain("Response.json({ status: 'ok' })") + expect(appContent).not.toContain('src/routes') + + const tsconfigContent = harness.fs.getContent('/workspace/api-app/tsconfig.json') + expect(tsconfigContent).toContain('env.d.ts') + }) + }) + + describe('error handling', () => { + test('fails when directory already exists', async () => { + // Pre-create the directory + harness.fs.addFile('/workspace/existing-project/dummy.txt', 'exists') + + const parsed = createParsedArgs('init', ['existing-project'], {}) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(1) + expect(harness.logger.error).toHaveBeenCalled() + + // Check error message mentions directory exists + const errorCalls = harness.logger.error.mock.calls + const hasExistsError = errorCalls.some( + (call) => String(call[0]).includes('already exists') + ) + expect(hasExistsError).toBe(true) + }) + }) + + describe('filesystem operations', () => { + test('creates nested directories correctly', async () => { + const parsed = createParsedArgs('init', ['my-app'], { + template: 'api' + }) + + const result = await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + expect(result.exitCode).toBe(0) + + // Check nested paths were created + expect(harness.fs.exists('/workspace/my-app/src/middleware')).toBe(true) + expect(harness.fs.exists('/workspace/my-app/src/app.ts')).toBe(true) + }) + + test('tracks all mkdir operations', async () => { + const parsed = createParsedArgs('init', ['tracked-app'], {}) + + await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + const mkdirOps = harness.fs.getOperations('mkdir') + expect(mkdirOps.length).toBeGreaterThan(0) + + // Root project dir should be created + expect(mkdirOps.some((op) => op.path.includes('/tracked-app'))).toBe(true) + }) + + test('tracks all writeFile operations', async () => { + const parsed = createParsedArgs('init', ['written-app'], {}) + + await runInitCommand( + parsed, + harness.logger as unknown as import('consola').ConsolaInstance, + { cwd: harness.cwd } + ) + + const writeOps = harness.fs.getOperations('writeFile') + expect(writeOps.length).toBeGreaterThanOrEqual(4) // At least 4 files in minimal template + }) + }) +}) diff --git a/packages/devflare/tests/integration/cli/packaged-install.test.ts b/packages/devflare/tests/integration/cli/packaged-install.test.ts new file mode 100644 index 0000000..07e7cf3 --- /dev/null +++ b/packages/devflare/tests/integration/cli/packaged-install.test.ts @@ -0,0 +1,51 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { access, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { cleanupTempDirs, installBuiltDevflare } from '../helpers/built-devflare.helpers' + +const tempDirs: string[] = [] +const runtimeDependencyNames = ['consola', 'pathe'] as const + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}) + +describe('packaged CLI install smoke', () => { + test('packaged devflare binary starts without loading the root TypeScript-backed bundle', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-cli-packaged-')) + tempDirs.push(projectDir) + + await installBuiltDevflare(projectDir, { + includeBin: true, + runtimeDependencies: runtimeDependencyNames + }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'packaged-cli-smoke', + private: true, + type: 'module' + }, null, 2)) + + await access(join(projectDir, 'node_modules', 'devflare', 'dist', 'cli', 'index.js')) + + const cli = Bun.spawn([ + 'bun', + join(projectDir, 'node_modules', 'devflare', 'bin', 'devflare.js'), + 'version' + ], { + cwd: projectDir, + stdout: 'pipe', + stderr: 'pipe' + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(cli.stdout).text(), + new Response(cli.stderr).text(), + cli.exited + ]) + + expect(exitCode).toBe(0) + expect(stdout).toContain('devflare v') + expect(stderr.trim()).toBe('') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/integration/cli/types-command.test.ts b/packages/devflare/tests/integration/cli/types-command.test.ts new file mode 100644 index 0000000..9c6910e --- /dev/null +++ b/packages/devflare/tests/integration/cli/types-command.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'pathe' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { runTypesCommand } from '../../../src/cli/commands/types' +import { createCliDependencies, createProcessRunner, successResult } from '../../helpers/process-runner' +import { createLogger } from '../../helpers/mock-logger' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../') + +function createUnusedProcessRunner() { + return createProcessRunner( + () => successResult(), + [], + { spawnErrorMessage: 'spawn() should not be called by runTypesCommand in this test' } + ) +} + +describe('runTypesCommand', () => { + let projectDir = '' + + beforeEach(async () => { + clearDependencies() + projectDir = await mkdtemp(join(tmpdir(), 'devflare-types-command-')) + await mkdir(join(projectDir, 'auth', 'src'), { recursive: true }) + }) + + afterEach(async () => { + clearDependencies() + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('preserves typed ref() service bindings when the main config is devflare.config.mts', async () => { + const indexImportPath = pathToFileURL(join(repoRoot, 'src', 'index.ts')).href + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'types-command-mts-test', + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.mts'), ` +import { defineConfig, ref } from '${indexImportPath}' + +const authWorker = ref(() => import('./auth/devflare.config')) + +export default defineConfig({ + name: 'gateway-worker', + compatibilityDate: '2026-03-17', + bindings: { + services: { + AUTH: authWorker.worker('AdminEntrypoint') + } + } +}) +`.trim()) + + await writeFile(join(projectDir, 'auth', 'devflare.config.ts'), ` +import { defineConfig } from '${indexImportPath}' + +export default defineConfig({ + name: 'auth-worker', + compatibilityDate: '2026-03-17' +}) +`.trim()) + + await writeFile(join(projectDir, 'auth', 'src', 'ep.admin.ts'), ` +export class AdminEntrypoint {} +`.trim()) + + await writeFile(join(projectDir, 'auth', 'src', 'admin.types.ts'), ` +export interface AdminEntrypointRpc { + ping(): Promise +} +`.trim()) + + setDependencies(createCliDependencies(createUnusedProcessRunner())) + + const logger = createLogger({ includeLog: false }) + const result = await runTypesCommand( + { command: 'types', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + + const generatedTypes = await readFile(join(projectDir, 'env.d.ts'), 'utf8') + expect(generatedTypes).toContain("import type { AdminEntrypointRpc } from './auth/src/admin.types'") + expect(generatedTypes).toContain('AUTH: AdminEntrypointRpc') + expect(generatedTypes).not.toContain('AUTH: Fetcher') + }) + + test('generates SendEmail env bindings', async () => { + const indexImportPath = pathToFileURL(join(repoRoot, 'src', 'index.ts')).href + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'types-command-send-email-test', + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +import { defineConfig } from '${indexImportPath}' + +export default defineConfig({ + name: 'send-email-worker', + compatibilityDate: '2026-03-17', + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'admin@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + } +}) +`.trim()) + + setDependencies(createCliDependencies(createUnusedProcessRunner())) + + const logger = createLogger({ includeLog: false }) + const result = await runTypesCommand( + { command: 'types', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + + const generatedTypes = await readFile(join(projectDir, 'env.d.ts'), 'utf8') + expect(generatedTypes).toContain("import type { SendEmail } from '@cloudflare/workers-types'") + expect(generatedTypes).toContain('EMAIL: SendEmail') + }) + + test('generates D1 env bindings when databases are configured by name', async () => { + const indexImportPath = pathToFileURL(join(repoRoot, 'src', 'index.ts')).href + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'types-command-d1-name-test', + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +import { defineConfig } from '${indexImportPath}' + +export default defineConfig({ + name: 'named-d1-worker', + compatibilityDate: '2026-03-17', + bindings: { + d1: { + DB: { name: 'app-db' } + } + } +}) +`.trim()) + + setDependencies(createCliDependencies(createUnusedProcessRunner())) + + const logger = createLogger({ includeLog: false }) + const result = await runTypesCommand( + { command: 'types', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + + const generatedTypes = await readFile(join(projectDir, 'env.d.ts'), 'utf8') + expect(generatedTypes).toContain("import type { D1Database } from '@cloudflare/workers-types'") + expect(generatedTypes).toContain('DB: D1Database') + }) + + test('generates Browser env bindings from map syntax', async () => { + const indexImportPath = pathToFileURL(join(repoRoot, 'src', 'index.ts')).href + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'types-command-browser-test', + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +import { defineConfig } from '${indexImportPath}' + +export default defineConfig({ + name: 'browser-worker', + compatibilityDate: '2026-03-17', + bindings: { + browser: { + TEST_BROWSER: 'browser-resource' + } + } +}) +`.trim()) + + setDependencies(createCliDependencies(createUnusedProcessRunner())) + + const logger = createLogger({ includeLog: false }) + const result = await runTypesCommand( + { command: 'types', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + + const generatedTypes = await readFile(join(projectDir, 'env.d.ts'), 'utf8') + expect(generatedTypes).toContain("import type { Fetcher } from '@cloudflare/workers-types'") + expect(generatedTypes).toContain('TEST_BROWSER: Fetcher') + }) + + test('generates a DevflareVars contract inferred from config vars', async () => { + const configEntryImportPath = pathToFileURL(join(repoRoot, 'src', 'config-entry.ts')).href + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'types-command-vars-test', + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +import { defineConfig, env } from '${configEntryImportPath}' + +export default defineConfig({ + name: 'vars-worker', + compatibilityDate: '2026-05-01', + vars: { + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE, + retries: env.RETRIES.parse(Number), + optionalLabel: env.OPTIONAL_LABEL.optional() + }, + flag: env.FLAG.default('enabled') + } +}) +`.trim()) + + setDependencies(createCliDependencies(createUnusedProcessRunner())) + + const logger = createLogger({ includeLog: false }) + const result = await runTypesCommand( + { command: 'types', args: [], options: {} }, + logger as any, + { cwd: projectDir } + ) + + expect(result.exitCode).toBe(0) + + const generatedTypes = await readFile(join(projectDir, 'env.d.ts'), 'utf8') + expect(generatedTypes).toContain("import type { InferConfigVars } from 'devflare/config'") + expect(generatedTypes).toContain("type __DevflareConfigVars = InferConfigVars>") + expect(generatedTypes).toContain('interface DevflareVars extends __DevflareConfigVars {}') + expect(generatedTypes).toContain('interface DevflareEnv extends __DevflareConfigVars {') + }) +}) diff --git a/packages/devflare/tests/integration/dev-server/case18-local-bindings.test.ts b/packages/devflare/tests/integration/dev-server/case18-local-bindings.test.ts new file mode 100644 index 0000000..f052f8c --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/case18-local-bindings.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { dirname, join } from 'pathe' +import { fileURLToPath } from 'node:url' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + deleteLocalSecret, + readLocalSecret, + writeLocalSecret +} from '../../../src/secrets/local-secrets' +import { ensurePackageBuilt, getAvailablePort } from '../helpers/built-devflare.helpers' +import { createCapturedLogger } from './worker-only-multi-surface.helpers' + +const TEST_TIMEOUT_MS = 60_000 +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../../..') +const caseDir = join(repoRoot, 'cases', 'case18') +const localSecretRef = { + cwd: caseDir, + storeId: 'case18-local-store', + name: 'api-token' +} + +interface LocalBindingsPayload { + secret: string + hyperdrive: { connectionString: string; database: string } + workflow: { id: string; status: string } + images: { width: number; contentType: string; status: number } + media: { contentType: string; status: number } + workerLoader: { status: number; text: string } + email: string +} + +async function waitForJson(url: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + let lastError: unknown = null + + while (Date.now() < deadline) { + try { + const response = await fetch(url) + const text = await response.text() + if (response.ok) { + return JSON.parse(text) as T + } + lastError = new Error(`HTTP ${response.status}: ${text}`) + } catch (error) { + lastError = error + } + + await Bun.sleep(300) + } + + throw lastError instanceof Error + ? lastError + : new Error(`Timed out waiting for JSON from ${url}`) +} + +async function syncSvelteKitCase(): Promise { + const sync = Bun.spawn(['bun', 'run', 'svelte-kit', 'sync'], { + cwd: caseDir, + stdout: 'pipe', + stderr: 'pipe' + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(sync.stdout).text(), + new Response(sync.stderr).text(), + sync.exited + ]) + + if (exitCode !== 0) { + throw new Error( + ['SvelteKit sync failed', stdout.trim(), stderr.trim()].filter(Boolean).join('\n\n') + ) + } +} + +describe('case18 SvelteKit local binding matrix', () => { + let devServer: DevServer | null = null + let vitePort = 0 + let miniflarePort = 0 + let previousSecret: string | undefined + + beforeAll(async () => { + previousSecret = readLocalSecret(localSecretRef) + writeLocalSecret({ + ...localSecretRef, + value: 'case18-secret-value' + }) + await ensurePackageBuilt() + await syncSvelteKitCase() + + vitePort = await getAvailablePort() + miniflarePort = await getAvailablePort() + const logger = createCapturedLogger() + + devServer = createDevServer({ + cwd: caseDir, + configPath: 'devflare.local-bindings.config.ts', + vitePort, + miniflarePort, + enableVite: true, + persist: false, + logger: logger as never + }) + + await devServer.start() + }, TEST_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + + if (previousSecret === undefined) { + deleteLocalSecret(localSecretRef) + } else { + writeLocalSecret({ + ...localSecretRef, + value: previousSecret + }) + } + }, TEST_TIMEOUT_MS) + + test('returns expected results from a SvelteKit API route', async () => { + const payload = await waitForJson( + `http://localhost:${vitePort}/api/local-bindings` + ) + + expect(payload.secret).toBe('case18-secret-value') + expect(payload.hyperdrive).toEqual({ + connectionString: 'postgres://case18:password@localhost:5432/case18', + database: 'case18' + }) + expect(payload.workflow.id).toBe('case18-order-1') + expect(['queued', 'running', 'complete', 'waiting']).toContain(payload.workflow.status) + expect(payload.images).toEqual({ + width: 1, + contentType: 'image/png', + status: 200 + }) + expect(payload.media).toEqual({ + contentType: 'video/mp4', + status: 200 + }) + expect(payload.workerLoader).toEqual({ + status: 200, + text: 'case18-loader-ok' + }) + expect(payload.email).toBe('sent') + }, TEST_TIMEOUT_MS) + + test('submits a SvelteKit server action that calls a ref service binding fetch', async () => { + const response = await fetch(`http://localhost:${vitePort}/service-action`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + email: 'creator@example.com' + }), + redirect: 'manual' + }) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('set-cookie')).toContain( + 'case18-service-action=creator%40example.com' + ) + expect(body).toContain('creator@example.com') + expect(body).toContain('case18-api') + expect(body).toContain('service-fetch') + expect(body).toContain('case18-var-value') + expect(body).toContain('undefined') + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/devflare/tests/integration/dev-server/ref-service-bindings.test.ts b/packages/devflare/tests/integration/dev-server/ref-service-bindings.test.ts new file mode 100644 index 0000000..d90cdc7 --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/ref-service-bindings.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { getAvailablePort } from '../helpers/built-devflare.helpers' +import { createCapturedLogger } from './worker-only-multi-surface.helpers' + +const TEST_TIMEOUT_MS = 60_000 + +async function waitForJson(url: string, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs + let lastError: unknown = null + + while (Date.now() < deadline) { + try { + const response = await fetch(url) + const text = await response.text() + if (response.ok) { + return JSON.parse(text) as T + } + lastError = new Error(`HTTP ${response.status}: ${text}`) + } catch (error) { + lastError = error + } + + await Bun.sleep(250) + } + + throw lastError instanceof Error + ? lastError + : new Error(`Timed out waiting for ${url}`) +} + +async function writeFixture(projectDir: string): Promise { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await mkdir(join(projectDir, 'api', 'src'), { recursive: true }) + + await writeFile(join(projectDir, 'src', 'fetch.ts'), ` +export default async function fetch(request: Request, env: any, ctx: ExecutionContext): Promise { + const result = await env.API.ping() + return Response.json({ result }) +} +`.trim()) + + await writeFile(join(projectDir, 'api', 'src', 'ep.api.ts'), ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class ApiEntrypoint extends WorkerEntrypoint { + async ping(): Promise { + const env = this.env as any + const row = await env.DB.prepare('select ?1 as value').bind('PONG').first() + await env.CACHE.put('last', row.value) + const id = env.COUNTER.idFromName('main') + const counter = env.COUNTER.get(id) + return [row.value, await env.CACHE.get('last'), await counter.ping(), env.FEATURE_FLAG].join(':') + } +} +`.trim()) + + await writeFile(join(projectDir, 'api', 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async ping(): Promise { + return 'DO_PONG' + } +} +`.trim()) + + await writeFile(join(projectDir, 'api', 'devflare.config.ts'), ` +export default { + name: 'api-worker', + compatibilityDate: '2026-04-28', + files: { + fetch: false, + entrypoints: 'src/ep.*.ts', + durableObjects: 'src/do.*.ts' + }, + vars: { + FEATURE_FLAG: 'enabled' + }, + bindings: { + kv: { + CACHE: { name: 'api-cache' } + }, + d1: { + DB: { name: 'api-db' } + }, + durableObjects: { + COUNTER: 'Counter' + } + } +} +`.trim()) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +const apiConfig = { + name: 'api-worker', + compatibilityDate: '2026-04-28', + files: { + fetch: false, + entrypoints: 'src/ep.*.ts', + durableObjects: 'src/do.*.ts' + }, + vars: { + FEATURE_FLAG: 'enabled' + }, + bindings: { + kv: { + CACHE: { name: 'api-cache' } + }, + d1: { + DB: { name: 'api-db' } + }, + durableObjects: { + COUNTER: 'Counter' + } + } +} + +const resolved = { + name: apiConfig.name, + config: apiConfig, + configPath: './api/devflare.config.ts' +} + +const apiRef = { + get name() { + return resolved.name + }, + get config() { + return resolved.config + }, + get configPath() { + return resolved.configPath + }, + __import: async () => ({ default: apiConfig }), + resolve: async () => resolved +} + +export default { + name: 'gateway-worker', + compatibilityDate: '2026-04-28', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + API: { + service: 'api-worker', + entrypoint: 'ApiEntrypoint', + __ref: apiRef + } + } + } +} +`.trim()) +} + +describe('dev server referenced service bindings', () => { + let projectDir: string + let devServer: DevServer | null = null + let miniflarePort = 0 + + beforeAll(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-dev-ref-service-')) + await writeFixture(projectDir) + miniflarePort = await getAvailablePort() + + devServer = createDevServer({ + cwd: projectDir, + miniflarePort, + enableVite: false, + persist: false, + logger: createCapturedLogger() as never + }) + + await devServer.start() + }, TEST_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }, TEST_TIMEOUT_MS) + + test('serves RPC from a referenced worker with its own local bindings', async () => { + const payload = await waitForJson<{ result: string }>(`http://127.0.0.1:${miniflarePort}/`) + + expect(payload).toEqual({ result: 'PONG:PONG:DO_PONG:enabled' }) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-hot-reload.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-hot-reload.test.ts new file mode 100644 index 0000000..040c7ea --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-hot-reload.test.ts @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'pathe' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + getAvailablePort, + installBuiltDevflare, + waitForResponseText +} from '../helpers/built-devflare.helpers' + +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 + +describe('worker-only dev server hot reload', () => { + let projectDir = '' + let devServer: DevServer | null = null + let port = 0 + let workerUrl = '' + let configPath = '' + let messagePath = '' + let localDefineConfigImportPath = '' + + const getConfigFileContent = (message: string) => ` +import { defineConfig } from '${localDefineConfigImportPath}' + +export default defineConfig({ + name: 'worker-only-hot-reload-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + vars: { + MESSAGE: '${message}' + } +}) +` + + beforeAll(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-worker-only-')) + port = await getAvailablePort() + workerUrl = `http://127.0.0.1:${port}/` + configPath = join(projectDir, 'devflare.config.ts') + messagePath = join(projectDir, 'src', 'lib', 'message.ts') + localDefineConfigImportPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../../src/index.ts' + ).replace(/\\/g, '/') + + await mkdir(join(projectDir, 'src', 'lib'), { recursive: true }) + await installBuiltDevflare(projectDir) + + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-only-hot-reload-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + + await writeFile(configPath, getConfigFileContent('before-config')) + + await writeFile(messagePath, `export const message = 'before'\n`) + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +import { message } from './lib/message' + +export default async function fetch(event) { + const url = event.url + + if (url.pathname === '/config') { + return new Response(String(event.env.MESSAGE)) + } + + return new Response(message) +} +` + ) + + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + await waitForResponseText(workerUrl, 'before') + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + test('serves the configured fetch worker when Vite is disabled', async () => { + const response = await fetch(workerUrl) + expect(response.status).toBe(200) + expect(await response.text()).toBe('before') + }) + + test('reloads imported worker modules without starting Vite', async () => { + await writeFile(messagePath, `export const message = 'after'\n`) + expect(await waitForResponseText(workerUrl, 'after')).toBe('after') + }, 15000) + + test('reloads devflare.config.ts changes in worker-only mode', async () => { + await writeFile(configPath, getConfigFileContent('after-config')) + expect(await waitForResponseText(`${workerUrl}config`, 'after-config')).toBe('after-config') + }, 15000) +}) + +describe('worker-only dev server late worker discovery', () => { + let projectDir = '' + let devServer: DevServer | null = null + let port = 0 + let workerUrl = '' + let fetchPath = '' + let localDefineConfigImportPath = '' + + beforeAll(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-worker-only-late-fetch-')) + port = await getAvailablePort() + workerUrl = `http://127.0.0.1:${port}/` + fetchPath = join(projectDir, 'src', 'fetch.ts') + localDefineConfigImportPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../../src/index.ts' + ).replace(/\\/g, '/') + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await installBuiltDevflare(projectDir) + + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-only-late-fetch-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +import { defineConfig } from '${localDefineConfigImportPath}' + +export default defineConfig({ + name: 'worker-only-late-fetch-test', + compatibilityDate: '2026-03-17' +}) +` + ) + + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + expect(await waitForResponseText(workerUrl, 'Devflare Bridge Gateway')).toBe( + 'Devflare Bridge Gateway' + ) + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + + if (projectDir) { + await rm(projectDir, { recursive: true, force: true }) + } + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + test('reloads when a default src/fetch.ts file is created after startup', async () => { + await writeFile( + fetchPath, + ` +export default { + async fetch() { + return new Response('late-fetch') + } +} +` + ) + + expect(await waitForResponseText(workerUrl, 'late-fetch')).toBe('late-fetch') + }, 10000) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-basic.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-basic.test.ts new file mode 100644 index 0000000..2f8934a --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-basic.test.ts @@ -0,0 +1,277 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + cleanupTempDirs, + createProject, + getAvailablePort, + readWorkerText, + waitForText +} from './worker-only-multi-surface.helpers' + +const tempDirs: string[] = [] +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 +const DEV_SERVER_TEST_TIMEOUT_MS = 20_000 + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}, DEV_SERVER_HOOK_TIMEOUT_MS) + +describe('worker-only dev server multi-surface handlers', () => { + test( + 'dispatches queue consumers configured via src/queue.ts', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-queue-', + config: ` +export default { + name: 'worker-only-queue-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts' + }, + bindings: { + kv: { + RESULTS: 'results-kv-id' + }, + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue' + } + ] + } + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +export default async function fetch(event) { + const url = event.url + + if (url.pathname === '/enqueue' && event.request.method === 'POST') { + await event.env.TASK_QUEUE.send({ value: 'queued' }) + return new Response('queued', { status: 202 }) + } + + if (url.pathname === '/result') { + return new Response((await event.env.RESULTS.get('queue-result')) ?? 'pending') + } + + return new Response('not-found', { status: 404 }) +} +`.trim(), + 'src/queue.ts': ` +export default async function queue(event, env, _ctx) { + for (const message of event.messages) { + await env.RESULTS.put('queue-result', String(message.body.value)) + message.ack() + } +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const enqueueResponse = await fetch(`${baseUrl}/enqueue`, { method: 'POST' }) + expect(enqueueResponse.status).toBe(202) + + expect(await waitForText(() => readWorkerText(`${baseUrl}/result`), 'queued')).toBe( + 'queued' + ) + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) + + test( + 'dispatches scheduled handlers configured via src/scheduled.ts', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-scheduled-', + config: ` +export default { + name: 'worker-only-scheduled-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + scheduled: 'src/scheduled.ts' + }, + bindings: { + kv: { + RESULTS: 'results-kv-id' + } + }, + triggers: { + crons: ['0 * * * *'] + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +export default async function fetch(event) { + const url = event.url + + if (url.pathname === '/result') { + return new Response((await event.env.RESULTS.get('scheduled-result')) ?? 'pending') + } + + return new Response('not-found', { status: 404 }) +} +`.trim(), + 'src/scheduled.ts': ` +export default async function scheduled(event, env, _ctx) { + await env.RESULTS.put('scheduled-result', event.cron || 'missing-cron') +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const miniflare = devServer.getMiniflare() as { + getWorker(workerName?: string): Promise<{ + scheduled(options?: { cron?: string; scheduledTime?: Date }): Promise + }> + } | null + if (!miniflare) { + throw new Error('Miniflare was not available after starting the dev server') + } + + const worker = await miniflare.getWorker('worker-only-scheduled-test') + await worker.scheduled({ + cron: '0 * * * *', + scheduledTime: new Date('2026-03-17T00:00:00.000Z') + }) + + expect(await waitForText(() => readWorkerText(`${baseUrl}/result`), '0 * * * *')).toBe( + '0 * * * *' + ) + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) + + test( + 'dispatches incoming email handlers configured via src/email.ts', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-email-', + config: ` +export default { + name: 'worker-only-email-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + email: 'src/email.ts' + }, + bindings: { + kv: { + EMAIL_LOG: 'email-log-kv-id' + } + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +export default async function fetch(event) { + const url = event.url + + if (url.pathname === '/result') { + return new Response((await event.env.EMAIL_LOG.get('email-result')) ?? 'pending') + } + + return new Response('not-found', { status: 404 }) +} +`.trim(), + 'src/email.ts': ` +export async function email(event, env) { + await env.EMAIL_LOG.put('email-result', event.from + '->' + event.to) +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const emailResponse = await fetch( + `${baseUrl}/cdn-cgi/handler/email?from=sender@example.com&to=worker@example.com`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + body: [ + 'From: sender@example.com', + 'To: worker@example.com', + 'Subject: Test email', + '', + 'Hello from the regression test' + ].join('\r\n') + } + ) + expect(emailResponse.status).toBe(200) + + expect( + await waitForText( + () => readWorkerText(`${baseUrl}/result`), + 'sender@example.com->worker@example.com' + ) + ).toBe('sender@example.com->worker@example.com') + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-events.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-events.test.ts new file mode 100644 index 0000000..2f06770 --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-events.test.ts @@ -0,0 +1,329 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + cleanupTempDirs, + createProject, + getAvailablePort, + readWorkerText, + waitForText +} from './worker-only-multi-surface.helpers' + +const tempDirs: string[] = [] +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 +const DEV_SERVER_TEST_TIMEOUT_MS = 15_000 + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}, DEV_SERVER_HOOK_TIMEOUT_MS) + +describe('worker-only dev server multi-surface handlers', () => { + test( + 'supports event-first handlers and AsyncLocalStorage getters across fetch, queue, scheduled, email, and Durable Objects', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-events-', + config: ` +export default { + name: 'worker-only-event-surface-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + kv: { + RESULTS: 'results-kv-id' + }, + durableObjects: { + LOGGER: 'Logger' + }, + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue' + } + ] + } + }, + triggers: { + crons: ['0 * * * *'] + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +import type { FetchEvent } from 'devflare/runtime' +import { getFetchEvent } from 'devflare/runtime' + +export async function fetch({ url, request, env }: FetchEvent): Promise { + const activeEvent = getFetchEvent() + + if (url.pathname === '/fetch') { + return Response.json({ + requestUrl: activeEvent.request.url, + eventUrl: activeEvent.url.href, + sameUrl: activeEvent.url === url, + safeInside: getFetchEvent.safe()?.url.href === request.url + }) + } + + if (url.pathname === '/queue' && request.method === 'POST') { + await env.TASK_QUEUE.send({ value: 'event-queued' }) + return new Response('queued', { status: 202 }) + } + + if (url.pathname === '/queue-result') { + return new Response((await env.RESULTS.get('queue')) ?? 'pending') + } + + if (url.pathname === '/scheduled-result') { + return new Response((await env.RESULTS.get('scheduled')) ?? 'pending') + } + + if (url.pathname === '/email-result') { + return new Response((await env.RESULTS.get('email')) ?? 'pending') + } + + if (url.pathname === '/do') { + const id = env.LOGGER.idFromName('event-style') + return env.LOGGER.get(id).fetch('http://do/inspect') + } + + return new Response('not-found', { status: 404 }) +} +`.trim(), + 'src/queue.ts': ` +import type { QueueEvent } from 'devflare/runtime' +import { getQueueEvent } from 'devflare/runtime' + +export async function queue(event: QueueEvent<{ value: string }, DevflareEnv>): Promise { + const activeEvent = getQueueEvent() + await event.env.RESULTS.put('queue', event.messages[0].body.value + ':' + activeEvent.batch.queue) + activeEvent.messages[0].ack() +} +`.trim(), + 'src/scheduled.ts': ` +import type { ScheduledEvent } from 'devflare/runtime' +import { getScheduledEvent } from 'devflare/runtime' + +export async function scheduled({ env, controller }: ScheduledEvent): Promise { + await env.RESULTS.put('scheduled', getScheduledEvent().controller.cron || controller.cron || 'missing-cron') +} +`.trim(), + 'src/email.ts': ` +import type { EmailEvent } from 'devflare/runtime' +import { getEmailEvent } from 'devflare/runtime' + +export async function email({ env, message }: EmailEvent): Promise { + await env.RESULTS.put('email', message.from + '->' + getEmailEvent().to) +} +`.trim(), + 'src/do/logger.ts': ` +import { DurableObject } from 'cloudflare:workers' +import type { DurableObjectFetchEvent } from 'devflare/runtime' +import { getDurableObjectFetchEvent } from 'devflare/runtime' + +export class Logger extends DurableObject { + async fetch({ request }: DurableObjectFetchEvent): Promise { + const activeEvent = getDurableObjectFetchEvent() + return new Response(activeEvent.request.url + '|' + request.url) + } +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const fetchResponse = await fetch(`${baseUrl}/fetch`) + expect(fetchResponse.status).toBe(200) + const fetchPayload = (await fetchResponse.json()) as { + requestUrl: string + eventUrl: string + sameUrl: boolean + safeInside: boolean + } + expect(fetchPayload).toEqual({ + requestUrl: `${baseUrl}/fetch`, + eventUrl: `${baseUrl}/fetch`, + sameUrl: true, + safeInside: true + }) + + const queueResponse = await fetch(`${baseUrl}/queue`, { method: 'POST' }) + expect(queueResponse.status).toBe(202) + expect( + await waitForText( + () => readWorkerText(`${baseUrl}/queue-result`), + 'event-queued:task-queue' + ) + ).toBe('event-queued:task-queue') + + const miniflare = devServer.getMiniflare() as { + getWorker(workerName?: string): Promise<{ + scheduled(options?: { cron?: string; scheduledTime?: Date }): Promise + }> + } | null + if (!miniflare) { + throw new Error('Miniflare was not available after starting the dev server') + } + + const worker = await miniflare.getWorker('worker-only-event-surface-test') + await worker.scheduled({ + cron: '0 * * * *', + scheduledTime: new Date('2026-03-17T00:00:00.000Z') + }) + + expect( + await waitForText(() => readWorkerText(`${baseUrl}/scheduled-result`), '0 * * * *') + ).toBe('0 * * * *') + + const emailResponse = await fetch( + `${baseUrl}/cdn-cgi/handler/email?from=sender@example.com&to=worker@example.com`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + body: [ + 'From: sender@example.com', + 'To: worker@example.com', + 'Subject: Test email', + '', + 'Hello from the event test' + ].join('\r\n') + } + ) + expect(emailResponse.status).toBe(200) + expect( + await waitForText( + () => readWorkerText(`${baseUrl}/email-result`), + 'sender@example.com->worker@example.com' + ) + ).toBe('sender@example.com->worker@example.com') + + const doResponse = await fetch(`${baseUrl}/do`) + expect(doResponse.status).toBe(200) + expect(await doResponse.text()).toBe('http://do/inspect|http://do/inspect') + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) + + test( + 'supports request-wide handle middleware with resolve(event) around HTTP method exports', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-handle-middleware-', + config: ` +export default { + name: 'worker-only-handle-middleware-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +import { createFetchEvent, sequence } from 'devflare/runtime' +import type { FetchEvent, ResolveFetch } from 'devflare/runtime' + +function appendOrder(current: string | null, part: string): string { + return current ? \`\${current}>\${part}\` : part +} + +function withOrder(event: FetchEvent, part: string): FetchEvent { + const headers = new Headers(event.request.headers) + headers.set('x-order', appendOrder(headers.get('x-order'), part)) + + return createFetchEvent( + new Request(event.request, { headers }), + event.env, + event.ctx, + { + locals: event.locals, + params: event.params + } + ) +} + +async function handle1(event: FetchEvent, resolve: ResolveFetch): Promise { + const response = await resolve(withOrder(event, 'handle1-before')) + const next = new Response(response.body, response) + next.headers.set('x-order', appendOrder(response.headers.get('x-order'), 'handle1-after')) + return next +} + +async function handle2(event: FetchEvent, resolve: ResolveFetch): Promise { + const response = await resolve(withOrder(event, 'handle2-before')) + const next = new Response(response.body, response) + next.headers.set('x-order', appendOrder(response.headers.get('x-order'), 'handle2-after')) + return next +} + +export const handle = sequence(handle1, handle2) + +export async function GET(event: FetchEvent): Promise { + const order = appendOrder(event.request.headers.get('x-order'), 'GET') + return new Response(order, { + headers: { + 'x-order': order + } + }) +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const response = await fetch(baseUrl) + expect(response.status).toBe(200) + expect(await response.text()).toBe('handle1-before>handle2-before>GET') + expect(response.headers.get('x-order')).toBe( + 'handle1-before>handle2-before>GET>handle2-after>handle1-after' + ) + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-logging.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-logging.test.ts new file mode 100644 index 0000000..8c6eadf --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface-logging.test.ts @@ -0,0 +1,183 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + cleanupTempDirs, + createCapturedLogger, + createProject, + getAvailablePort, + waitForLogEntry +} from './worker-only-multi-surface.helpers' + +const tempDirs: string[] = [] +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 +const DEV_SERVER_TEST_TIMEOUT_MS = 15_000 + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}, DEV_SERVER_HOOK_TIMEOUT_MS) + +describe('worker-only dev server multi-surface handlers', () => { + test( + 'logs from fetch, durable objects, queues, scheduled handlers and email handlers reach the dev logger', + async () => { + const projectDir = await createProject(tempDirs, { + prefix: 'devflare-worker-only-logs-', + config: ` +export default { + name: 'worker-only-log-surface-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts', + durableObjects: 'src/do/**/*.ts' + }, + bindings: { + durableObjects: { + LOGGER: 'Logger' + }, + queues: { + producers: { + TASK_QUEUE: 'task-queue' + }, + consumers: [ + { + queue: 'task-queue' + } + ] + } + }, + triggers: { + crons: ['0 * * * *'] + } +} +`.trim(), + files: { + 'src/fetch.ts': ` +export default async function fetch(event) { + const url = event.url + + if (url.pathname === '/fetch-log') { + console.log('FETCH_LOG_FROM_HANDLER') + return new Response('fetch-ok') + } + + if (url.pathname === '/do-log') { + const id = event.env.LOGGER.idFromName('logs') + return event.env.LOGGER.get(id).fetch('http://do/log') + } + + if (url.pathname === '/queue-log' && event.request.method === 'POST') { + await event.env.TASK_QUEUE.send({ surface: 'queue' }) + return new Response('queued', { status: 202 }) + } + + return new Response('not-found', { status: 404 }) +} +`.trim(), + 'src/queue.ts': ` +export default async function queue(batch) { + console.log('QUEUE_LOG_FROM_HANDLER', batch.messages.length) + for (const message of batch.messages) { + message.ack() + } +} +`.trim(), + 'src/scheduled.ts': ` +export default async function scheduled(controller) { + console.log('SCHEDULED_LOG_FROM_HANDLER', controller.cron || 'missing-cron') +} +`.trim(), + 'src/email.ts': ` +export async function email(message) { + console.log('EMAIL_LOG_FROM_HANDLER', message.from, message.to) +} +`.trim(), + 'src/do/logger.ts': ` +import { DurableObject } from 'cloudflare:workers' + +export class Logger extends DurableObject { + async fetch() { + console.log('DO_LOG_FROM_HANDLER') + return new Response('do-ok') + } +} +`.trim() + } + }) + + const port = await getAvailablePort() + const baseUrl = `http://127.0.0.1:${port}` + const logger = createCapturedLogger() + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false, + logger: logger as unknown as import('consola').ConsolaInstance + }) + + await devServer.start() + + const fetchResponse = await fetch(`${baseUrl}/fetch-log`) + expect(fetchResponse.status).toBe(200) + expect(await fetchResponse.text()).toBe('fetch-ok') + + const doResponse = await fetch(`${baseUrl}/do-log`) + expect(doResponse.status).toBe(200) + expect(await doResponse.text()).toBe('do-ok') + + const queueResponse = await fetch(`${baseUrl}/queue-log`, { method: 'POST' }) + expect(queueResponse.status).toBe(202) + + const miniflare = devServer.getMiniflare() as { + getWorker(workerName?: string): Promise<{ + scheduled(options?: { cron?: string; scheduledTime?: Date }): Promise + }> + } | null + if (!miniflare) { + throw new Error('Miniflare was not available after starting the dev server') + } + + const worker = await miniflare.getWorker('worker-only-log-surface-test') + await worker.scheduled({ + cron: '0 * * * *', + scheduledTime: new Date('2026-03-17T00:00:00.000Z') + }) + + const emailResponse = await fetch( + `${baseUrl}/cdn-cgi/handler/email?from=sender@example.com&to=worker@example.com`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + body: [ + 'From: sender@example.com', + 'To: worker@example.com', + 'Subject: Test email', + '', + 'Hello from the regression test' + ].join('\r\n') + } + ) + expect(emailResponse.status).toBe(200) + + expect((await waitForLogEntry(logger, 'FETCH_LOG_FROM_HANDLER')).level).toBe('log') + expect((await waitForLogEntry(logger, 'DO_LOG_FROM_HANDLER')).level).toBe('log') + expect((await waitForLogEntry(logger, 'QUEUE_LOG_FROM_HANDLER')).level).toBe('log') + expect((await waitForLogEntry(logger, 'SCHEDULED_LOG_FROM_HANDLER')).level).toBe('log') + expect((await waitForLogEntry(logger, 'EMAIL_LOG_FROM_HANDLER')).level).toBe('log') + } finally { + if (devServer) { + await devServer.stop() + } + } + }, + DEV_SERVER_TEST_TIMEOUT_MS + ) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-multi-surface.helpers.ts b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface.helpers.ts new file mode 100644 index 0000000..a775743 --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-multi-surface.helpers.ts @@ -0,0 +1,127 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'pathe' +import { + cleanupTempDirs, + fetchTextWithTimeout, + getAvailablePort, + installBuiltDevflare, + waitForText +} from '../helpers/built-devflare.helpers' + +export { cleanupTempDirs, getAvailablePort, waitForText } from '../helpers/built-devflare.helpers' + +export interface MultiSurfaceProjectOptions { + prefix: string + config: string + files: Record +} + +export interface CapturedLogEntry { + level: string + message: string +} + +export interface CapturedLogger { + messages: CapturedLogEntry[] + log: (...args: unknown[]) => void + info: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void + success: (...args: unknown[]) => void + debug: (...args: unknown[]) => void +} +export async function createProject( + tempDirs: string[], + options: MultiSurfaceProjectOptions +): Promise { + const projectDir = await mkdtemp(join(tmpdir(), options.prefix)) + tempDirs.push(projectDir) + + await installBuiltDevflare(projectDir) + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: options.prefix, + private: true, + type: 'module' + }, null, 2)) + + await writeFile(join(projectDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, null, 2)) + + await writeFile(join(projectDir, 'devflare.config.ts'), options.config) + + for (const [relativePath, content] of Object.entries(options.files)) { + const absolutePath = join(projectDir, relativePath) + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, content) + } + + return projectDir +} + +export async function readWorkerText(url: string): Promise { + return await fetchTextWithTimeout(url) +} + +function formatLogValue(value: unknown): string { + if (typeof value === 'string') { + return value + } + + if (value instanceof Error) { + return value.stack ?? value.message + } + + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +export function createCapturedLogger(): CapturedLogger { + const messages: CapturedLogEntry[] = [] + const capture = (level: string) => (...args: unknown[]) => { + messages.push({ + level, + message: args.map((arg) => formatLogValue(arg)).join(' ') + }) + } + + return { + messages, + log: capture('log'), + info: capture('info'), + warn: capture('warn'), + error: capture('error'), + success: capture('success'), + debug: capture('debug') + } +} + +export async function waitForLogEntry( + logger: CapturedLogger, + expectedText: string, + timeoutMs = 8000 +): Promise { + const deadline = Date.now() + timeoutMs + let lastSeen = '' + + while (Date.now() < deadline) { + const matchedEntry = logger.messages.find((entry) => entry.message.includes(expectedText)) + if (matchedEntry) { + return matchedEntry + } + + lastSeen = logger.messages.map((entry) => `${entry.level}: ${entry.message}`).join('\n') + await new Promise((resolvePromise) => setTimeout(resolvePromise, 200)) + } + + throw new Error(`Timed out waiting for log containing "${expectedText}". Captured logs:\n${lastSeen}`) +} diff --git a/packages/devflare/tests/integration/dev-server/worker-only-root-env.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-root-env.test.ts new file mode 100644 index 0000000..815d722 --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-root-env.test.ts @@ -0,0 +1,350 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + cleanupTempDirs, + getAvailablePort, + installBuiltDevflare, + waitForResponseText +} from '../helpers/built-devflare.helpers' + +const tempDirs: string[] = [] +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}, DEV_SERVER_HOOK_TIMEOUT_MS) + +describe('worker-only dev server root env imports', () => { + test('starts successfully when the fetch worker imports env from the root package', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-root-env-worker-')) + tempDirs.push(projectDir) + + await installBuiltDevflare(projectDir) + + const port = await getAvailablePort() + const workerUrl = `http://127.0.0.1:${port}/` + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-root-env-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'worker-root-env-test', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + files: { + fetch: 'src/fetch.ts' + }, + vars: { + MESSAGE: 'ok' + } +} +`.trim() + ) + + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +import { env } from 'devflare' + +export default { + async fetch() { + return new Response(String(env.MESSAGE)) + } +} +`.trim() + ) + + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + expect(await waitForResponseText(workerUrl, 'ok')).toBe('ok') + } finally { + if (devServer) { + await devServer.stop() + } + } + }) + + test('supports sendEmail bindings when the fetch worker imports env from the root package', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-root-env-send-email-worker-')) + tempDirs.push(projectDir) + + await installBuiltDevflare(projectDir) + + const port = await getAvailablePort() + const workerUrl = `http://127.0.0.1:${port}/` + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-root-env-send-email-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'worker-root-env-send-email-test', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + } +} +`.trim() + ) + + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +import { env } from 'devflare' + +export default { + async fetch() { + await env.EMAIL.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Hello from worker', + text: 'Sent from worker-only dev server' + }) + return new Response('sent') + } +} +`.trim() + ) + + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + expect(await waitForResponseText(workerUrl, 'sent')).toBe('sent') + } finally { + if (devServer) { + await devServer.stop() + } + } + }) + + test('starts successfully when worker code pulls in Svelte-style server helpers with dynamic import fallbacks', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-root-env-svelte-worker-')) + tempDirs.push(projectDir) + + await installBuiltDevflare(projectDir) + + const port = await getAvailablePort() + const workerUrl = `http://127.0.0.1:${port}/` + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await mkdir(join(projectDir, 'node_modules', 'svelte', 'src', 'internal', 'server'), { + recursive: true + }) + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-root-env-svelte-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'node_modules', 'svelte', 'package.json'), + JSON.stringify( + { + name: 'svelte', + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'worker-root-env-svelte-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +} +`.trim() + ) + + await writeFile( + join(projectDir, 'node_modules', 'svelte', 'src', 'internal', 'server', 'render-context.js'), + ` +let als = null +let als_import = null +const noop = () => {} + +export function hasAls() { + return Boolean(als) +} + +export async function init_render_context() { + als_import ??= import('node:async_hooks').then((hooks) => { + als = new hooks.AsyncLocalStorage() + }).then(noop, noop) + return als_import +} +`.trim() + ) + await writeFile( + join(projectDir, 'node_modules', 'svelte', 'src', 'internal', 'server', 'crypto.js'), + ` +let cryptoValue +const obfuscated_import = (module_name) => import( + /* @vite-ignore */ + module_name +) + +export async function cryptoMode() { + cryptoValue ??= globalThis.crypto?.subtle?.digest + ? globalThis.crypto + : (await obfuscated_import('node:crypto')).webcrypto + + return cryptoValue ? 'crypto-ready' : 'crypto-missing' +} +`.trim() + ) + + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +import { cryptoMode } from 'svelte/src/internal/server/crypto.js' +import { hasAls, init_render_context } from 'svelte/src/internal/server/render-context.js' + +export default { + async fetch() { + await init_render_context() + return Response.json({ + als: hasAls(), + crypto: await cryptoMode() + }) + } +} +`.trim() + ) + + let devServer: DevServer | null = null + + try { + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + + const response = await fetch(workerUrl) + expect(response.status).toBe(200) + expect((await response.json()) as Record).toEqual({ + als: true, + crypto: 'crypto-ready' + }) + } finally { + if (devServer) { + await devServer.stop() + } + } + }, 15000) +}) diff --git a/packages/devflare/tests/integration/dev-server/worker-only-routes.test.ts b/packages/devflare/tests/integration/dev-server/worker-only-routes.test.ts new file mode 100644 index 0000000..b5d624e --- /dev/null +++ b/packages/devflare/tests/integration/dev-server/worker-only-routes.test.ts @@ -0,0 +1,224 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { createDevServer, type DevServer } from '../../../src/dev-server' +import { + cleanupTempDirs, + getAvailablePort, + installBuiltDevflare, + waitForResponseText +} from '../helpers/built-devflare.helpers' + +const tempDirs: string[] = [] +const DEV_SERVER_HOOK_TIMEOUT_MS = 20_000 + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}, DEV_SERVER_HOOK_TIMEOUT_MS) + +describe('worker-only dev server file routes', () => { + let projectDir = '' + let devServer: DevServer | null = null + let port = 0 + let workerUrl = '' + let userRoutePath = '' + + beforeAll(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-worker-only-routes-')) + tempDirs.push(projectDir) + port = await getAvailablePort() + workerUrl = `http://127.0.0.1:${port}` + userRoutePath = join(projectDir, 'src', 'routes', 'users', '[id].ts') + + await mkdir(join(projectDir, 'src', 'routes', 'users'), { recursive: true }) + await installBuiltDevflare(projectDir) + + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-only-routes-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'worker-only-routes-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + routes: { + dir: 'src/routes', + prefix: '/api' + } + } +} +`.trim() + ) + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +import { sequence } from 'devflare/runtime' + +export const handle = sequence(async (event, resolve) => { + const response = await resolve(event) + const next = new Response(response.body, response) + next.headers.set('x-route-id', event.params.id ?? 'none') + return next +}) +`.trim() + ) + await writeFile( + userRoutePath, + ` +export async function GET(event): Promise { + return new Response(String(event.params.id)) +} +`.trim() + ) + + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + await waitForResponseText(`${workerUrl}/api/users/42`, '42') + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + test('dispatches route files and exposes params to outer fetch middleware', async () => { + const response = await fetch(`${workerUrl}/api/users/42`) + expect(response.status).toBe(200) + expect(await response.text()).toBe('42') + expect(response.headers.get('x-route-id')).toBe('42') + }) + + test('reloads route files in worker-only mode', async () => { + await writeFile( + userRoutePath, + ` +export async function GET(event): Promise { + return new Response(String(event.params.id) + '-updated') +} +`.trim() + ) + + expect(await waitForResponseText(`${workerUrl}/api/users/42`, '42-updated')).toBe('42-updated') + }) +}) + +describe('worker-only dev server late route discovery', () => { + let projectDir = '' + let devServer: DevServer | null = null + let port = 0 + let workerUrl = '' + let routePath = '' + + beforeAll(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'devflare-worker-only-late-routes-')) + tempDirs.push(projectDir) + port = await getAvailablePort() + workerUrl = `http://127.0.0.1:${port}/` + routePath = join(projectDir, 'src', 'routes', 'index.ts') + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await installBuiltDevflare(projectDir) + + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'worker-only-late-routes-test', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler' + } + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'worker-only-late-routes-test', + compatibilityDate: '2026-03-17' +} +`.trim() + ) + + devServer = createDevServer({ + cwd: projectDir, + miniflarePort: port, + enableVite: false, + persist: false + }) + + await devServer.start() + expect(await waitForResponseText(workerUrl, 'Devflare Bridge Gateway')).toBe( + 'Devflare Bridge Gateway' + ) + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + afterAll(async () => { + if (devServer) { + await devServer.stop() + } + }, DEV_SERVER_HOOK_TIMEOUT_MS) + + test('reloads when a default src/routes/index.ts file is created after startup', async () => { + await mkdir(join(projectDir, 'src', 'routes'), { recursive: true }) + await writeFile( + routePath, + ` +export async function GET(): Promise { + return new Response('late-route') +} +`.trim() + ) + + expect(await waitForResponseText(workerUrl, 'late-route')).toBe('late-route') + }, 10000) +}) diff --git a/packages/devflare/tests/integration/examples/configs.test.ts b/packages/devflare/tests/integration/examples/configs.test.ts new file mode 100644 index 0000000..3affe1c --- /dev/null +++ b/packages/devflare/tests/integration/examples/configs.test.ts @@ -0,0 +1,853 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, readdirSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { resolve } from 'pathe' +import { + type DevflareConfig, + compileBuildConfig, + compileConfig, + isPreviewScopedName, + loadConfig, + resolveConfigForEnvironment +} from '../../../src/config' +import { collectPreviewScopedResourcePlan } from '../../../src/config/preview-resources' +import { brandAsLocalConfig } from '../../../src/config/resolve-phased' +import { resolveConfigForLocalRuntime } from '../../../src/config/resource-resolution' +import { createMockEnv } from '../../../src/test' + +const repoRoot = resolve(import.meta.dirname, '../../../../../') +const casesDir = resolve(repoRoot, 'cases') +const testingAppDir = resolve(repoRoot, 'apps/testing') +const documentationAppDir = resolve(repoRoot, 'apps/documentation') +const documentationConfigModulePath = pathToFileURL( + resolve(documentationAppDir, 'devflare.config.ts') +).href +const testingFetchModulePath = pathToFileURL(resolve(testingAppDir, 'src/fetch.ts')).href +const testingQueueModulePath = pathToFileURL(resolve(testingAppDir, 'src/queue.ts')).href +const testingScheduledModulePath = pathToFileURL(resolve(testingAppDir, 'src/scheduled.ts')).href + +interface TestingStatusSummary { + appName: string + deploymentChannel: string + smokeEnabled: boolean + hasDurableObjectBindings: boolean + hasServiceBindings: boolean + hasVectorizeBindings: boolean + hasAnalyticsBindings: boolean + hasSendEmailBindings: boolean + hasHyperdriveBinding: boolean + bindings: Record + lastSmokeResult: TestingSmokeResult | null + lastQueueJobs: TestingQueueState | null + lastQueueEmails: TestingQueueState | null + lastScheduledRun: TestingScheduledState | null +} + +interface TestingSmokeResult { + runId: string + startedAt: string + completedAt: string + ok: boolean + results: Record +} + +interface TestingSmokeSummary extends TestingSmokeResult { + appName: string + deploymentChannel: string +} + +interface TestingSmokeErrorSummary { + ok: false + error: string + smokeEnabled: boolean +} + +interface TestingQueueState { + appName: string + queue: string + messageCount: number + lastMessage: unknown + processedAt: string +} + +interface TestingScheduledState { + appName: string + cron: string + scheduledTime: number + ranAt: string +} + +interface TestingExampleHarness { + env: Record + sentEmails: Array<{ binding: string; message: unknown }> + analyticsWrites: Array<{ binding: string; point: unknown }> + serviceCalls: string[] + browserRequests: string[] + jobsQueue: { _getMessages(): Array<{ body: unknown; options?: unknown }> } + emailsQueue: { _getMessages(): Array<{ body: unknown; options?: unknown }> } +} + +function createExecutionContext(): ExecutionContext { + return { + props: {}, + waitUntil() {}, + passThroughOnException() {} + } as unknown as ExecutionContext +} + +function createTestingExampleEnv(config: DevflareConfig): TestingExampleHarness { + const sentEmails: Array<{ binding: string; message: unknown }> = [] + const analyticsWrites: Array<{ binding: string; point: unknown }> = [] + const serviceCalls: string[] = [] + const browserRequests: string[] = [] + const sessionRoomMembers = new Set() + const collaborationEvents: Array<{ actor: string; kind: string; target: string }> = [] + let lockOwner: string | null = null + let lockExpiresAt = 0 + + const searchVectors = new Map }>() + const documentVectors = new Map< + string, + { values: number[]; metadata?: Record } + >() + + const durableObjectBindings = { + SESSION_ROOM: { + getByName: () => ({ + async touchMember(memberId: string) { + sessionRoomMembers.add(memberId) + return { + activeMembers: [...sessionRoomMembers].sort(), + updatedAt: new Date().toISOString() + } + }, + async getSummary() { + return { + activeMembers: [...sessionRoomMembers].sort(), + memberCount: sessionRoomMembers.size + } + } + }) + }, + COLLABORATION_STATE: { + getByName: () => ({ + async recordChange(change: { actor: string; kind: string; target: string }) { + collaborationEvents.push(change) + return { + events: [...collaborationEvents], + updatedAt: new Date().toISOString() + } + }, + async getSummary() { + return { + events: [...collaborationEvents], + eventCount: collaborationEvents.length + } + } + }) + }, + CROSS_WORKER_LOCK: { + getByName: () => ({ + async acquire(owner: string, ttlMs = 60_000) { + const now = Date.now() + if (!lockOwner || lockExpiresAt <= now || lockOwner === owner) { + lockOwner = owner + lockExpiresAt = now + ttlMs + return { + acquired: true, + owner: lockOwner, + expiresAt: lockExpiresAt + } + } + + return { + acquired: false, + owner: lockOwner, + expiresAt: lockExpiresAt + } + }, + async status() { + return lockOwner + ? { + owner: lockOwner, + expiresAt: lockExpiresAt + } + : null + } + }) + } + } + + const authServiceName = + config.bindings?.services?.AUTH_SERVICE.service ?? 'devflare-testing-auth-service' + const adminServiceName = config.bindings?.services?.ADMIN_RPC.service ?? authServiceName + const searchServiceName = + config.bindings?.services?.SEARCH_SERVICE.service ?? 'devflare-testing-search-service' + + const serviceBindings = { + AUTH_SERVICE: { + getServiceInfo: () => { + serviceCalls.push('AUTH_SERVICE:getServiceInfo') + return { + service: authServiceName, + version: '1.0.0' + } + }, + issueServiceToken: (subject: string) => { + serviceCalls.push('AUTH_SERVICE:issueServiceToken') + return { + subject, + token: `testing-token-${subject}`, + scopes: ['smoke:run'] + } + } + }, + ADMIN_RPC: { + async getHealth() { + serviceCalls.push('ADMIN_RPC:getHealth') + return { + status: 'healthy', + service: adminServiceName + } + }, + async runDiagnostics() { + serviceCalls.push('ADMIN_RPC:runDiagnostics') + return { + queueBacklog: 0 + } + } + }, + SEARCH_SERVICE: { + getServiceInfo: () => { + serviceCalls.push('SEARCH_SERVICE:getServiceInfo') + return { + service: searchServiceName, + channel: 'staging' + } + }, + search: (query: string) => { + serviceCalls.push('SEARCH_SERVICE:search') + return { + query, + results: [{ id: '1', title: `Result for ${query}`, score: 0.99 }] + } + } + } + } + + const documentIndexName = + config.bindings?.vectorize?.DOCUMENT_INDEX.indexName ?? 'devflare-testing-document-index' + const searchIndexName = + config.bindings?.vectorize?.SEARCH_INDEX.indexName ?? 'devflare-testing-search-index' + + const vectorizeBindings = { + DOCUMENT_INDEX: { + async describe() { + return { + name: documentIndexName + } + }, + async upsert( + vectors: Array<{ id: string; values: number[]; metadata?: Record }> + ) { + for (const vector of vectors) { + documentVectors.set(vector.id, { + values: vector.values, + metadata: vector.metadata + }) + } + + return { + count: vectors.length, + ids: vectors.map((vector) => vector.id) + } + } + }, + SEARCH_INDEX: { + async describe() { + return { + name: searchIndexName + } + }, + async upsert( + vectors: Array<{ id: string; values: number[]; metadata?: Record }> + ) { + for (const vector of vectors) { + searchVectors.set(vector.id, { + values: vector.values, + metadata: vector.metadata + }) + } + + return { + count: vectors.length, + ids: vectors.map((vector) => vector.id) + } + }, + async query() { + const firstMatch = [...searchVectors.entries()][0] + return { + matches: firstMatch + ? [{ id: firstMatch[0], metadata: firstMatch[1].metadata, score: 0.99 }] + : [] + } + } + } + } + + const hyperdriveBindings = { + POSTGRES: { + async query() { + return [{ ok: 1 }] + } + } + } + + const browserBindings = Object.fromEntries( + Object.keys(config.bindings?.browser ?? {}).map((name) => [ + name, + { + fetch: async (request: Request) => { + browserRequests.push(`${name}:${new URL(request.url).pathname}`) + return new Response(`${name}-ok`, { + status: 200 + }) + } + } + ]) + ) + + const analyticsBindings = Object.fromEntries( + Object.keys(config.bindings?.analyticsEngine ?? {}).map((name) => [ + name, + { + writeDataPoint: (point: unknown) => { + analyticsWrites.push({ + binding: name, + point + }) + } + } + ]) + ) + + const sendEmailBindings = Object.fromEntries( + Object.keys(config.bindings?.sendEmail ?? {}).map((name) => [ + name, + { + send: async (message: unknown) => { + sentEmails.push({ + binding: name, + message + }) + } + } + ]) + ) + + const aiBinding = config.bindings?.ai + ? { + [config.bindings.ai.binding]: { + run: async (...args: unknown[]) => ({ + ok: true, + argsLength: args.length + }) + } + } + : {} + + const baseEnv = createMockEnv({ + kv: Object.keys(config.bindings?.kv ?? {}), + d1: Object.keys(config.bindings?.d1 ?? {}), + r2: Object.keys(config.bindings?.r2 ?? {}), + queues: Object.keys(config.bindings?.queues?.producers ?? {}), + vars: config.vars, + secrets: Object.fromEntries( + Object.keys(config.secrets ?? {}).map((name) => [name, `${name.toLowerCase()}-value`]) + ) + }) + + const jobsQueue = baseEnv.JOBS as { _getMessages(): Array<{ body: unknown; options?: unknown }> } + const emailsQueue = baseEnv.EMAILS as { + _getMessages(): Array<{ body: unknown; options?: unknown }> + } + + const env = createMockEnv({ + custom: { + ...baseEnv, + ...durableObjectBindings, + ...serviceBindings, + ...aiBinding, + ...vectorizeBindings, + ...hyperdriveBindings, + ...browserBindings, + ...analyticsBindings, + ...sendEmailBindings + } + }) + + return { + env, + sentEmails, + analyticsWrites, + serviceCalls, + browserRequests, + jobsQueue, + emailsQueue + } +} + +async function runTestingFetch( + config: DevflareConfig, + path: string, + init: RequestInit | undefined, + harness: TestingExampleHarness, + type: 'status' +): Promise<{ summary: TestingStatusSummary; harness: TestingExampleHarness }> +async function runTestingFetch( + config: DevflareConfig, + path: string, + init: RequestInit | undefined, + harness: TestingExampleHarness, + type: 'smoke' +): Promise<{ summary: TestingSmokeSummary; harness: TestingExampleHarness }> +async function runTestingFetch( + config: DevflareConfig, + path: string, + init: RequestInit | undefined, + harness: TestingExampleHarness, + type: 'smoke-error' +): Promise<{ summary: TestingSmokeErrorSummary; harness: TestingExampleHarness }> +async function runTestingFetch( + config: DevflareConfig, + path: string, + init?: RequestInit, + harness = createTestingExampleEnv(config), + type: 'status' | 'smoke' | 'smoke-error' = 'status' +): Promise<{ + summary: TestingStatusSummary | TestingSmokeSummary | TestingSmokeErrorSummary + harness: TestingExampleHarness +}> { + const { fetch } = (await import(testingFetchModulePath)) as { + fetch(request: Request, env: Record, ctx: ExecutionContext): Promise + } + const request = new Request(`https://example.com${path}`, init) + const response = await fetch(request, harness.env, createExecutionContext()) + + return { + summary: (await response.json()) as + | TestingStatusSummary + | TestingSmokeSummary + | TestingSmokeErrorSummary, + harness + } +} + +async function runTestingQueue( + config: DevflareConfig, + batch: { queue: string; messages: Array<{ body: unknown; ack?(): void }> } +): Promise { + const { queue } = (await import(testingQueueModulePath)) as { + queue( + batch: { queue: string; messages: Array<{ body: unknown; ack?(): void }> }, + env: Record + ): Promise + } + const harness = createTestingExampleEnv(config) + await queue(batch, harness.env) + return harness +} + +async function runTestingScheduled(config: DevflareConfig): Promise { + const { scheduled } = (await import(testingScheduledModulePath)) as { + scheduled( + controller: { cron: string; scheduledTime?: number }, + env: Record + ): Promise + } + const harness = createTestingExampleEnv(config) + await scheduled( + { + cron: '0 */6 * * *', + scheduledTime: 1_700_000_000_000 + }, + harness.env + ) + return harness +} + +describe('repo example app configs', () => { + test('case example roots do not keep stale generated wrangler configs', () => { + const caseDirectories = readdirSync(casesDir, { withFileTypes: true }).filter( + (entry) => entry.isDirectory() && /^case\d+(?:-.*)?$/.test(entry.name) + ) + + const staleConfigFiles = caseDirectories.flatMap((entry) => { + return ['wrangler.json', 'wrangler.jsonc'] + .filter((fileName) => existsSync(resolve(casesDir, entry.name, fileName))) + .map((fileName) => `${entry.name}/${fileName}`) + }) + + expect(staleConfigFiles).toEqual([]) + }) + + test('apps/testing covers the full binding matrix, uses preview-scoped resource names, and keeps production overrides', async () => { + const config = await loadConfig({ cwd: testingAppDir }) + const preview = resolveConfigForEnvironment(config, 'preview') + const production = resolveConfigForEnvironment(config, 'production') + const compiled = compileConfig(resolveConfigForLocalRuntime(config)) + const previewWrangler = compileBuildConfig(config, 'preview') + const productionWrangler = compileBuildConfig(config, 'production') + + expect(Object.keys(config.bindings?.kv ?? {}).sort()).toEqual(['CACHE', 'SESSIONS']) + expect(Object.keys(config.bindings?.d1 ?? {}).sort()).toEqual([ + 'AUDIT_DB', + 'PRIMARY_DB', + 'REPORTING_DB' + ]) + expect(Object.keys(config.bindings?.r2 ?? {}).sort()).toEqual(['ARCHIVE', 'ASSETS']) + expect(Object.keys(config.bindings?.durableObjects ?? {}).sort()).toEqual([ + 'COLLABORATION_STATE', + 'CROSS_WORKER_LOCK', + 'SESSION_ROOM' + ]) + expect(Object.keys(config.bindings?.queues?.producers ?? {}).sort()).toEqual(['EMAILS', 'JOBS']) + expect(config.bindings?.queues?.consumers).toHaveLength(2) + expect(Object.keys(config.bindings?.services ?? {}).sort()).toEqual([ + 'ADMIN_RPC', + 'AUTH_SERVICE', + 'SEARCH_SERVICE' + ]) + expect(isPreviewScopedName(config.bindings?.kv?.CACHE)).toBe(true) + expect(isPreviewScopedName(config.bindings?.kv?.SESSIONS)).toBe(true) + expect(isPreviewScopedName(config.bindings?.d1?.PRIMARY_DB)).toBe(true) + expect(isPreviewScopedName(config.bindings?.r2?.ASSETS)).toBe(true) + expect(isPreviewScopedName(config.bindings?.queues?.producers?.JOBS)).toBe(true) + expect(isPreviewScopedName(config.bindings?.vectorize?.DOCUMENT_INDEX.indexName)).toBe(true) + expect( + isPreviewScopedName((config.bindings?.hyperdrive?.POSTGRES as { name: string }).name) + ).toBe(true) + expect(isPreviewScopedName(config.bindings?.browser?.BROWSER)).toBe(true) + expect(isPreviewScopedName(config.bindings?.analyticsEngine?.APP_ANALYTICS.dataset)).toBe(true) + expect(config.bindings?.ai).toEqual({ binding: 'AI' }) + expect(Object.keys(config.bindings?.vectorize ?? {}).sort()).toEqual([ + 'DOCUMENT_INDEX', + 'SEARCH_INDEX' + ]) + expect(Object.keys(config.bindings?.hyperdrive ?? {})).toEqual(['POSTGRES']) + expect(config.compatibilityFlags).toEqual(expect.arrayContaining(['nodejs_compat'])) + expect(config.previews?.includeCrons).toBe(false) + expect(Object.keys(config.bindings?.analyticsEngine ?? {}).sort()).toEqual([ + 'APP_ANALYTICS', + 'SEARCH_ANALYTICS' + ]) + expect(Object.keys(config.bindings?.sendEmail ?? {}).sort()).toEqual([ + 'SUPPORT_EMAIL', + 'TRANSACTIONAL_EMAIL' + ]) + + expect(preview.vars?.APP_NAME).toBe('testing-binding-matrix-preview') + expect(preview.vars?.DEPLOYMENT_CHANNEL).toBe('preview') + expect(preview.secrets?.API_TOKEN?.required).toBe(false) + expect(previewWrangler.secrets).toBeUndefined() + expect(preview.bindings?.kv?.CACHE).toBe('devflare-testing-cache-kv-preview') + expect(preview.bindings?.kv?.SESSIONS).toBe('devflare-testing-sessions-kv-preview') + expect(preview.bindings?.d1?.PRIMARY_DB).toBe('devflare-testing-primary-db-preview') + expect(preview.bindings?.d1?.AUDIT_DB).toBe('devflare-testing-audit-db-preview') + expect(preview.bindings?.r2?.ASSETS).toBe('devflare-testing-assets-bucket-preview') + expect(preview.bindings?.r2?.ARCHIVE).toBe('devflare-testing-archive-bucket-preview') + expect(preview.bindings?.queues?.producers?.JOBS).toBe('devflare-testing-jobs-queue-preview') + expect(preview.bindings?.queues?.producers?.EMAILS).toBe( + 'devflare-testing-emails-queue-preview' + ) + expect(preview.bindings?.queues?.consumers?.[0]?.queue).toBe( + 'devflare-testing-jobs-queue-preview' + ) + expect(preview.bindings?.queues?.consumers?.[0]?.deadLetterQueue).toBe( + 'devflare-testing-jobs-dlq-preview' + ) + expect(preview.bindings?.vectorize?.DOCUMENT_INDEX.indexName).toBe( + 'devflare-testing-document-index-preview' + ) + expect(preview.bindings?.vectorize?.SEARCH_INDEX.indexName).toBe( + 'devflare-testing-search-index-preview' + ) + expect(preview.bindings?.hyperdrive?.POSTGRES).toEqual({ + name: 'devflare-testing-preview', + previewFallback: 'base' + }) + expect(preview.bindings?.browser?.BROWSER).toBe('devflare-testing-browser-preview') + expect(preview.bindings?.analyticsEngine?.APP_ANALYTICS.dataset).toBe( + 'devflare-testing-app-analytics-preview' + ) + expect(preview.triggers?.crons).toEqual(['0 */6 * * *']) + expect(compiled.queues?.consumers).toHaveLength(2) + expect(compiled.triggers?.crons).toEqual(['0 */6 * * *']) + expect(compiled.services).toEqual( + expect.arrayContaining([ + { + binding: 'AUTH_SERVICE', + service: 'devflare-testing-auth-service' + }, + { + binding: 'ADMIN_RPC', + service: 'devflare-testing-auth-service', + entrypoint: 'AdminEntrypoint' + }, + { + binding: 'SEARCH_SERVICE', + service: 'devflare-testing-search-service' + } + ]) + ) + expect(compiled.hyperdrive).toEqual([{ binding: 'POSTGRES', id: 'devflare-testing' }]) + expect(compiled.r2_buckets).toEqual( + expect.arrayContaining([ + { binding: 'ASSETS', bucket_name: 'devflare-testing-assets-bucket' }, + { binding: 'ARCHIVE', bucket_name: 'devflare-testing-archive-bucket' } + ]) + ) + expect(compiled.analytics_engine_datasets).toEqual( + expect.arrayContaining([ + { binding: 'APP_ANALYTICS', dataset: 'devflare-testing-app-analytics' }, + { binding: 'SEARCH_ANALYTICS', dataset: 'devflare-testing-search-analytics' } + ]) + ) + + expect(production.vars?.APP_NAME).toBe('testing-binding-matrix-production') + expect(production.vars?.DEPLOYMENT_CHANNEL).toBe('production') + expect(production.secrets?.API_TOKEN?.required).toBe(true) + expect(productionWrangler.secrets).toEqual({ required: ['API_TOKEN'] }) + expect(production.bindings?.kv?.CACHE).toBe('devflare-testing-cache-kv-production') + expect(production.bindings?.kv?.SESSIONS).toBe('devflare-testing-sessions-kv') + expect(production.bindings?.r2?.ASSETS).toBe('devflare-testing-assets-bucket-production') + expect(production.bindings?.r2?.ARCHIVE).toBe('devflare-testing-archive-bucket') + expect(production.bindings?.vectorize?.DOCUMENT_INDEX.indexName).toBe( + 'devflare-testing-document-index' + ) + }) + + test('apps/testing preview resource planning keeps stable base names while targeting a branch preview scope', async () => { + const originalPreviewBranch = process.env.DEVFLARE_PREVIEW_BRANCH + const originalPreviewIdentifier = process.env.DEVFLARE_PREVIEW_IDENTIFIER + + try { + process.env.DEVFLARE_PREVIEW_BRANCH = 'next' + process.env.DEVFLARE_PREVIEW_IDENTIFIER = 'next' + + const config = await loadConfig({ cwd: testingAppDir }) + const plan = collectPreviewScopedResourcePlan(config, { + environment: 'preview' + }) + + expect( + plan.kv.map((ref) => ({ + baseName: ref.baseName, + previewName: ref.previewName + })) + ).toEqual([ + { + baseName: 'devflare-testing-cache-kv', + previewName: 'devflare-testing-cache-kv-next' + }, + { + baseName: 'devflare-testing-sessions-kv', + previewName: 'devflare-testing-sessions-kv-next' + } + ]) + expect(plan.d1.map((ref) => ref.previewName)).toEqual([ + 'devflare-testing-primary-db-next', + 'devflare-testing-audit-db-next', + 'devflare-testing-reporting-db-next' + ]) + expect(plan.queues.map((ref) => ref.previewName).sort()).toEqual([ + 'devflare-testing-emails-dlq-next', + 'devflare-testing-emails-queue-next', + 'devflare-testing-jobs-dlq-next', + 'devflare-testing-jobs-queue-next' + ]) + expect( + plan.hyperdrive.map((ref) => ({ + baseName: ref.baseName, + previewName: ref.previewName + })) + ).toEqual([ + { + baseName: 'devflare-testing', + previewName: 'devflare-testing-next' + } + ]) + } finally { + if (originalPreviewBranch === undefined) { + delete process.env.DEVFLARE_PREVIEW_BRANCH + } else { + process.env.DEVFLARE_PREVIEW_BRANCH = originalPreviewBranch + } + + if (originalPreviewIdentifier === undefined) { + delete process.env.DEVFLARE_PREVIEW_IDENTIFIER + } else { + process.env.DEVFLARE_PREVIEW_IDENTIFIER = originalPreviewIdentifier + } + } + }) + + test('apps/testing default routes stay cheap and smoke stays guarded until explicitly invoked', async () => { + const config = await loadConfig({ cwd: testingAppDir }) + const preview = resolveConfigForEnvironment(config, 'preview') + + const previewRun = await runTestingFetch( + preview, + '/', + undefined, + createTestingExampleEnv(preview), + 'status' + ) + expect(previewRun.summary.appName).toBe('testing-binding-matrix-preview') + expect(previewRun.summary.deploymentChannel).toBe('preview') + expect(previewRun.summary.smokeEnabled).toBe(true) + expect(previewRun.summary.hasDurableObjectBindings).toBe(true) + expect(previewRun.summary.hasServiceBindings).toBe(true) + expect(previewRun.summary.hasVectorizeBindings).toBe(true) + expect(previewRun.summary.hasAnalyticsBindings).toBe(true) + expect(previewRun.summary.hasSendEmailBindings).toBe(true) + expect(previewRun.summary.hasHyperdriveBinding).toBe(true) + expect(previewRun.summary.lastSmokeResult).toBeNull() + expect(previewRun.harness.sentEmails).toHaveLength(0) + expect(previewRun.harness.analyticsWrites).toHaveLength(0) + expect(previewRun.harness.serviceCalls).toHaveLength(0) + expect(previewRun.harness.browserRequests).toHaveLength(0) + expect(previewRun.harness.jobsQueue._getMessages()).toHaveLength(0) + expect(previewRun.harness.emailsQueue._getMessages()).toHaveLength(0) + expect('AI' in previewRun.harness.env).toBe(true) + expect('SESSION_ROOM' in previewRun.harness.env).toBe(true) + expect('POSTGRES' in previewRun.harness.env).toBe(true) + + const unauthorizedRun = await runTestingFetch( + preview, + '/smoke', + { + method: 'POST' + }, + createTestingExampleEnv(preview), + 'smoke-error' + ) + expect(unauthorizedRun.summary.ok).toBe(false) + expect(unauthorizedRun.summary.error).toBe('Missing or invalid X-Devflare-Smoke-Key header') + }) + + test('apps/testing guarded smoke route exercises the full matrix and persists status', async () => { + const config = await loadConfig({ cwd: testingAppDir }) + const preview = resolveConfigForEnvironment(config, 'preview') + const harness = createTestingExampleEnv(preview) + const smokeRun = await runTestingFetch( + preview, + '/smoke', + { + method: 'POST', + headers: { + 'X-Devflare-Smoke-Key': 'smoke_key-value' + } + }, + harness, + 'smoke' + ) + + expect(smokeRun.summary.appName).toBe('testing-binding-matrix-preview') + expect(smokeRun.summary.ok).toBe(true) + expect(smokeRun.summary.results.kv.ok).toBe(true) + expect(smokeRun.summary.results.d1.ok).toBe(true) + expect(smokeRun.summary.results.r2.ok).toBe(true) + expect(smokeRun.summary.results.durableObjects.ok).toBe(true) + expect(smokeRun.summary.results.queues.ok).toBe(true) + expect(smokeRun.summary.results.services.ok).toBe(true) + expect(smokeRun.summary.results.ai.ok).toBe(true) + expect(smokeRun.summary.results.vectorize.ok).toBe(true) + expect(smokeRun.summary.results.hyperdrive.ok).toBe(true) + expect(smokeRun.summary.results.browser.ok).toBe(true) + expect(smokeRun.summary.results.analytics.ok).toBe(true) + expect(smokeRun.summary.results.sendEmail.ok).toBe(true) + expect(smokeRun.harness.sentEmails).toHaveLength(2) + expect(smokeRun.harness.analyticsWrites).toHaveLength(2) + expect(smokeRun.harness.serviceCalls).toEqual([ + 'AUTH_SERVICE:getServiceInfo', + 'AUTH_SERVICE:issueServiceToken', + 'ADMIN_RPC:getHealth', + 'ADMIN_RPC:runDiagnostics', + 'SEARCH_SERVICE:getServiceInfo', + 'SEARCH_SERVICE:search' + ]) + expect(smokeRun.harness.browserRequests).toEqual(['BROWSER:/']) + expect(smokeRun.harness.jobsQueue._getMessages()).toHaveLength(1) + expect(smokeRun.harness.emailsQueue._getMessages()).toHaveLength(1) + + const statusRun = await runTestingFetch(preview, '/status', undefined, harness, 'status') + expect(statusRun.summary.lastSmokeResult?.runId).toBe(smokeRun.summary.runId) + }) + + test('apps/testing queue and scheduled handlers record their latest activity in KV', async () => { + const config = await loadConfig({ cwd: testingAppDir }) + const preview = resolveConfigForEnvironment(config, 'preview') + + const queueHarness = await runTestingQueue(preview, { + queue: + preview.bindings?.queues?.consumers?.[0]?.queue ?? 'devflare-testing-jobs-queue-preview', + messages: [{ body: { type: 'job-smoke' } }] + }) + const queueState = await (queueHarness.env.SESSIONS as KVNamespace).get( + 'testing:queue:jobs:last' + ) + expect(queueState).not.toBeNull() + + const scheduledHarness = await runTestingScheduled(preview) + const scheduledState = await (scheduledHarness.env.SESSIONS as KVNamespace).get( + 'testing:scheduled:last-run' + ) + expect(scheduledState).not.toBeNull() + }) + + test('apps/documentation compiles into a preview-capable generated Wrangler config', async () => { + const config = await loadConfig({ cwd: documentationAppDir }) + const compiled = compileConfig(brandAsLocalConfig(config)) + + expect(compiled.name).toBe('devflare-docs') + expect(compiled.main).toBe('.adapter-cloudflare/_worker.js') + expect(compiled.assets).toEqual({ + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }) + expect(compiled.preview_urls).toBe(true) + expect(compiled.workers_dev).toBe(true) + expect(config.files?.fetch).toBe(false) + expect(config.previews?.includeCrons).toBe(false) + expect(config.assets).toEqual({ + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }) + expect(config.wrangler?.passthrough).toEqual({ + main: '.adapter-cloudflare/_worker.js' + }) + }) + + test('apps/documentation resolves a dedicated worker name for named preview deploys', async () => { + const originalPreviewIdentifier = process.env.DEVFLARE_PREVIEW_IDENTIFIER + + try { + process.env.DEVFLARE_PREVIEW_IDENTIFIER = 'pr-1' + + const documentationConfigModule = await import( + `${documentationConfigModulePath}?preview-worker-name-test=${Date.now()}` + ) + const compiled = compileConfig(brandAsLocalConfig(documentationConfigModule.default)) + + expect(documentationConfigModule.default.name).toBe('devflare-docs-pr-1') + expect(compiled.name).toBe('devflare-docs-pr-1') + } finally { + if (originalPreviewIdentifier === undefined) { + delete process.env.DEVFLARE_PREVIEW_IDENTIFIER + } else { + process.env.DEVFLARE_PREVIEW_IDENTIFIER = originalPreviewIdentifier + } + } + }) +}) diff --git a/packages/devflare/tests/integration/helpers/built-devflare.helpers.ts b/packages/devflare/tests/integration/helpers/built-devflare.helpers.ts new file mode 100644 index 0000000..c5c50f7 --- /dev/null +++ b/packages/devflare/tests/integration/helpers/built-devflare.helpers.ts @@ -0,0 +1,192 @@ +import { cp, mkdir, rm } from 'node:fs/promises' +import { createServer } from 'node:net' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'pathe' + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../') +let buildPromise: Promise | null = null +const RETRYABLE_COPY_ERROR_CODES = new Set(['EBADF', 'EBUSY', 'EMFILE', 'ENFILE', 'EPERM']) + +export interface InstallBuiltDevflareOptions { + includeBin?: boolean + runtimeDependencies?: readonly string[] +} + +export async function ensurePackageBuilt(): Promise { + if (!buildPromise) { + buildPromise = (async () => { + const build = Bun.spawn(['bun', 'run', 'build'], { + cwd: packageRoot, + stdout: 'pipe', + stderr: 'pipe' + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(build.stdout).text(), + new Response(build.stderr).text(), + build.exited + ]) + + if (exitCode !== 0) { + throw new Error( + ['Package build failed', stdout.trim(), stderr.trim()].filter(Boolean).join('\n\n') + ) + } + })() + } + + await buildPromise +} + +async function retryCopy(operation: () => Promise): Promise { + const attempts = 5 + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await operation() + return + } catch (error) { + const code = (error as { code?: unknown }).code + if ( + attempt >= attempts || + typeof code !== 'string' || + !RETRYABLE_COPY_ERROR_CODES.has(code) + ) { + throw error + } + + await Bun.sleep(100 * attempt) + } + } +} + +export async function installBuiltDevflare( + projectDir: string, + options: InstallBuiltDevflareOptions = {} +): Promise { + await ensurePackageBuilt() + + await mkdir(join(projectDir, 'node_modules'), { recursive: true }) + + const packagedDevflareDir = join(projectDir, 'node_modules', 'devflare') + await mkdir(packagedDevflareDir, { recursive: true }) + await retryCopy(() => + cp(join(packageRoot, 'package.json'), join(packagedDevflareDir, 'package.json')) + ) + await retryCopy(() => + cp(join(packageRoot, 'dist'), join(packagedDevflareDir, 'dist'), { recursive: true }) + ) + + if (options.includeBin) { + await retryCopy(() => + cp(join(packageRoot, 'bin'), join(packagedDevflareDir, 'bin'), { recursive: true }) + ) + } + + for (const dependencyName of options.runtimeDependencies ?? []) { + await retryCopy(() => + cp( + join(packageRoot, 'node_modules', dependencyName), + join(projectDir, 'node_modules', dependencyName), + { recursive: true, dereference: true } + ) + ) + } +} + +export async function cleanupTempDirs(tempDirs: string[]): Promise { + for (const tempDir of tempDirs) { + try { + await rm(tempDir, { recursive: true, force: true }) + } catch (error) { + // Best-effort cleanup. On CI, lingering workerd file handles or + // transient kernel errors (e.g. EFAULT on Linux runners) can make + // `rm -rf` fail even with `force: true`. The OS will reclaim the + // temp dir; failing afterAll here would mask real test results. + console.warn(`[cleanupTempDirs] failed to remove ${tempDir}:`, error) + } + } +} + +export async function getAvailablePort(): Promise { + return await new Promise((resolvePromise, rejectPromise) => { + const server = createServer() + + server.on('error', rejectPromise) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => rejectPromise(new Error('Could not determine an available port'))) + return + } + + const { port } = address + server.close((error) => { + if (error) { + rejectPromise(error) + return + } + + resolvePromise(port) + }) + }) + }) +} + +export async function waitForText( + getText: () => Promise, + expectedText: string, + timeoutMs = 8000 +): Promise { + const deadline = Date.now() + timeoutMs + let lastText = '' + let lastError: unknown = null + + while (Date.now() < deadline) { + try { + const text = await getText() + lastText = text + if (text === expectedText) { + return text + } + lastError = new Error(`Expected "${expectedText}", received "${text}"`) + } catch (error) { + lastError = error + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 200)) + } + + if (lastError instanceof Error) { + throw lastError + } + + throw new Error(`Timed out waiting for "${expectedText}". Last value: "${lastText}"`) +} + +export async function fetchTextWithTimeout(url: string, timeoutMs = 1000): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { signal: controller.signal }) + return await response.text() + } finally { + clearTimeout(timeout) + } +} + +export async function waitForResponseText( + url: string, + expectedText: string, + timeoutMs = 8000 +): Promise { + const requestTimeoutMs = Math.min(1000, timeoutMs) + + return await waitForText( + async () => { + return await fetchTextWithTimeout(url, requestTimeoutMs) + }, + expectedText, + timeoutMs + ) +} diff --git a/packages/devflare/tests/integration/mocks/harness.ts b/packages/devflare/tests/integration/mocks/harness.ts new file mode 100644 index 0000000..1673a41 --- /dev/null +++ b/packages/devflare/tests/integration/mocks/harness.ts @@ -0,0 +1,219 @@ +// ============================================================================= +// CLI Test Harness โ€” Integration testing utilities +// ============================================================================= + +import { mock, type Mock } from 'bun:test' +import { createVirtualFS, VirtualFileSystem } from './virtual-fs' +import { createMockExeca, MockExeca, createEmptyMockExeca } from './mock-execa' + +/** + * Test harness configuration + */ +export interface TestHarnessOptions { + /** Initial working directory */ + cwd?: string + /** Pre-populate files */ + files?: Record + /** Use empty execa mock (no default handlers) */ + emptyExeca?: boolean + /** Silent logger (suppress all output) */ + silent?: boolean +} + +/** + * Logger interface matching Consola + */ +export interface TestLogger { + info: Mock<(...args: unknown[]) => void> + warn: Mock<(...args: unknown[]) => void> + error: Mock<(...args: unknown[]) => void> + success: Mock<(...args: unknown[]) => void> + debug: Mock<(...args: unknown[]) => void> + log: Mock<(...args: unknown[]) => void> + messages: Array<{ level: string; args: unknown[] }> +} + +/** + * Test harness for CLI integration tests + */ +export interface TestHarness { + /** Virtual file system */ + fs: VirtualFileSystem + /** Mock execa */ + execa: MockExeca + /** Mock logger with captured messages */ + logger: TestLogger + /** Current working directory */ + cwd: string + /** Clean up and reset */ + reset: () => void + /** Inject mocks into a module */ + withMocks: (fn: () => T | Promise) => Promise +} + +/** + * Create a test logger that captures all messages + */ +function createTestLogger(silent: boolean = true): TestLogger { + const messages: Array<{ level: string; args: unknown[] }> = [] + + const createMethod = (level: string) => { + return mock((...args: unknown[]) => { + messages.push({ level, args }) + if (!silent) { + console.log(`[${level}]`, ...args) + } + }) + } + + return { + info: createMethod('info'), + warn: createMethod('warn'), + error: createMethod('error'), + success: createMethod('success'), + debug: createMethod('debug'), + log: createMethod('log'), + messages + } +} + +/** + * Create a test harness for CLI integration tests + */ +export function createTestHarness(options: TestHarnessOptions = {}): TestHarness { + const cwd = options.cwd || '/project' + const fs = createVirtualFS() + const execa = options.emptyExeca ? createEmptyMockExeca() : createMockExeca() + const logger = createTestLogger(options.silent !== false) + + // Set up initial file system state + fs.setCwd(cwd) + + // Pre-populate files + if (options.files) { + for (const [path, content] of Object.entries(options.files)) { + fs.addFile(path.startsWith('/') ? path : `${cwd}/${path}`, content) + } + } + + const reset = () => { + fs.reset() + execa.reset() + logger.messages.length = 0 + + // Reset pre-populated files + fs.setCwd(cwd) + if (options.files) { + for (const [path, content] of Object.entries(options.files)) { + fs.addFile(path.startsWith('/') ? path : `${cwd}/${path}`, content) + } + } + } + + const withMocks = async (fn: () => T | Promise): Promise => { + // Store original imports + const originalFsImport = await import('node:fs/promises') + const originalExeca = await import('execa') + + // Mock the modules + // Note: In Bun, we need to use a different approach + // We'll pass the mocks to the functions that need them + + try { + return await fn() + } finally { + // Restore (handled by test isolation) + } + } + + return { + fs, + execa, + logger, + cwd, + reset, + withMocks + } +} + +/** + * Create parsed args for testing commands + */ +export function createParsedArgs( + command: string, + args: string[] = [], + options: Record = {} +) { + return { + command, + args, + options + } +} + +/** + * Standard project files for a devflare project + */ +export const STANDARD_PROJECT_FILES = { + 'package.json': JSON.stringify({ + name: 'test-project', + version: '0.0.1', + type: 'module', + dependencies: {}, + devDependencies: { + devflare: '^0.1.0', + vite: '^5.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + }, null, 2), + 'devflare.config.ts': `import { defineConfig } from 'devflare/config' + +export default defineConfig({ + name: 'test-project', + compatibilityDate: '2024-01-01', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + kv: { CACHE: 'cache-ns' }, + d1: { DB: 'my-database' } + }, + vars: { + API_URL: 'https://api.example.com' + } +}) +`, + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true + } + }, null, 2), + 'vite.config.ts': `import { defineConfig } from 'vite' +import { cloudflare } from '@cloudflare/vite-plugin' + +export default defineConfig({ + plugins: [cloudflare()] +}) +`, + 'src/fetch.ts': `export async function fetch(_request: Request): Promise { + return new Response('Hello World!') +} +` +} + +/** + * Create a harness with standard project files + */ +export function createStandardProjectHarness( + extraFiles?: Record +): TestHarness { + return createTestHarness({ + files: { + ...STANDARD_PROJECT_FILES, + ...extraFiles + } + }) +} diff --git a/packages/devflare/tests/integration/mocks/index.ts b/packages/devflare/tests/integration/mocks/index.ts new file mode 100644 index 0000000..99f2462 --- /dev/null +++ b/packages/devflare/tests/integration/mocks/index.ts @@ -0,0 +1,23 @@ +// ============================================================================= +// Integration Test Mocks โ€” Index +// ============================================================================= + +export { createVirtualFS, VirtualFileSystem } from './virtual-fs' +export { + createMockExeca, + createEmptyMockExeca, + createMockProcessRunner, + MockExeca, + type CommandExecution, + type MockExecResult, + type CommandMatcher +} from './mock-execa' +export { + createTestHarness, + createStandardProjectHarness, + createParsedArgs, + STANDARD_PROJECT_FILES, + type TestHarness, + type TestHarnessOptions, + type TestLogger +} from './harness' diff --git a/packages/devflare/tests/integration/mocks/mock-execa.ts b/packages/devflare/tests/integration/mocks/mock-execa.ts new file mode 100644 index 0000000..75ecd3e --- /dev/null +++ b/packages/devflare/tests/integration/mocks/mock-execa.ts @@ -0,0 +1,353 @@ +// ============================================================================= +// Mock Execa โ€” Deep mock for subprocess simulation +// ============================================================================= + +import type { Result, Options } from 'execa' + +/** + * Command execution record for assertions + */ +export interface CommandExecution { + command: string + args: string[] + options: Options + result: MockExecResult +} + +/** + * Mock execution result + */ +export interface MockExecResult { + exitCode: number + stdout: string + stderr: string + failed: boolean + killed: boolean + signal?: string +} + +/** + * Command matcher - can be string, regex, or function + */ +export type CommandMatcher = + | string + | RegExp + | ((command: string, args: string[]) => boolean) + +/** + * Mock command handler + */ +export interface MockCommandHandler { + matcher: CommandMatcher + handler: ( + command: string, + args: string[], + options: Options + ) => MockExecResult | Promise +} + +/** + * Mock Execa implementation for testing CLI commands + * that spawn subprocesses + */ +export class MockExeca { + private handlers: MockCommandHandler[] = [] + public executions: CommandExecution[] = [] + + private getBunxExecutableArgs(args: string[]): string[] { + const firstExecutableIndex = args.findIndex((arg) => !arg.startsWith('-')) + + return firstExecutableIndex >= 0 + ? args.slice(firstExecutableIndex) + : args + } + + /** + * Default result for unmatched commands + */ + private defaultResult: MockExecResult = { + exitCode: 0, + stdout: '', + stderr: '', + failed: false, + killed: false + } + + /** + * Register a command handler + */ + on( + matcher: CommandMatcher, + handler: MockCommandHandler['handler'] + ): this { + this.handlers.push({ matcher, handler }) + return this + } + + /** + * Register a simple success response for a command + */ + onCommand( + matcher: CommandMatcher, + result: Partial = {} + ): this { + return this.on(matcher, () => ({ + ...this.defaultResult, + ...result + })) + } + + /** + * Register a failure response for a command + */ + onCommandFail( + matcher: CommandMatcher, + stderr: string = 'Command failed' + ): this { + return this.on(matcher, () => ({ + exitCode: 1, + stdout: '', + stderr, + failed: true, + killed: false + })) + } + + /** + * Match a command against handlers + */ + private findHandler( + command: string, + args: string[] + ): MockCommandHandler | undefined { + const fullCommand = `${command} ${args.join(' ')}`.trim() + // For bunx commands, the actual tool is in args + const executableArgs = command === 'bunx' + ? this.getBunxExecutableArgs(args) + : args + const effectiveCommand = command === 'bunx' && executableArgs.length > 0 + ? executableArgs.join(' ') + : fullCommand + + return this.handlers.find((h) => { + if (typeof h.matcher === 'string') { + // Match: "vite build" against bunx-wrapped invocations by checking + // effectiveCommand, while still allowing direct local CLI paths like + // ".../node_modules/vite/bin/vite.js build" via fullCommand matching. + // Or match exact full command + return effectiveCommand.startsWith(h.matcher) || + fullCommand.startsWith(h.matcher) || + effectiveCommand === h.matcher || + fullCommand === h.matcher || + command === h.matcher || + (command === 'bunx' && executableArgs[0] === h.matcher) + } + if (h.matcher instanceof RegExp) { + return h.matcher.test(fullCommand) || h.matcher.test(effectiveCommand) + } + return h.matcher(command, args) + }) + } + + /** + * Execute a mock command + */ + async execa( + command: string, + args: string[] = [], + options: Options = {} + ): Promise> { + const handler = this.findHandler(command, args) + + let result: MockExecResult + + if (handler) { + result = await handler.handler(command, args, options) + } else { + // Return default success for unhandled commands + result = { ...this.defaultResult } + } + + // Record the execution + this.executions.push({ + command, + args, + options, + result + }) + + // Build execa-like response + const response = { + command: `${command} ${args.join(' ')}`, + escapedCommand: `${command} ${args.map((a) => `"${a}"`).join(' ')}`, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + all: result.stdout + result.stderr, + failed: result.failed, + timedOut: false, + isCanceled: false, + killed: result.killed, + signal: result.signal, + signalDescription: result.signal ? `Signal: ${result.signal}` : undefined, + cwd: options.cwd as string || process.cwd(), + durationMs: 0 + } as unknown as Result + + // For testing, we return the result even on failure + // (unlike real execa which throws) + // This allows tests to inspect the result without try/catch + return response + } + + /** + * Get executions of a specific command + */ + getExecutions(command?: string): CommandExecution[] { + if (!command) return this.executions + return this.executions.filter( + (e) => e.command === command || e.command.includes(command) + ) + } + + /** + * Check if a command was executed + */ + wasExecuted(matcher: CommandMatcher): boolean { + return this.executions.some((e) => { + const fullCommand = `${e.command} ${e.args.join(' ')}`.trim() + if (typeof matcher === 'string') { + return fullCommand.includes(matcher) + } + if (matcher instanceof RegExp) { + return matcher.test(fullCommand) + } + return matcher(e.command, e.args) + }) + } + + /** + * Get count of executions matching a pattern + */ + executionCount(matcher: CommandMatcher): number { + return this.executions.filter((e) => { + const fullCommand = `${e.command} ${e.args.join(' ')}`.trim() + if (typeof matcher === 'string') { + return fullCommand.includes(matcher) + } + if (matcher instanceof RegExp) { + return matcher.test(fullCommand) + } + return matcher(e.command, e.args) + }).length + } + + /** + * Clear all executions + */ + clearExecutions(): void { + this.executions = [] + } + + /** + * Clear handlers and executions + */ + reset(): void { + this.handlers = [] + this.executions = [] + } + + /** + * Create the mock module that can replace 'execa' + */ + createMock() { + const self = this + return { + execa: self.execa.bind(self), + execaSync: () => { + throw new Error('execaSync is not supported in mock') + }, + $: self.execa.bind(self) + } + } +} + +/** + * Pre-configured mock execa for common devflare commands + */ +export function createMockExeca(): MockExeca { + const mock = new MockExeca() + + // Default handlers for common commands + mock.onCommand('vite', { + exitCode: 0, + stdout: 'vite dev server running at http://localhost:5173' + }) + + mock.onCommand('vite build', { + exitCode: 0, + stdout: 'Build successful' + }) + + mock.onCommand('wrangler', { + exitCode: 0, + stdout: 'wrangler v3.0.0' + }) + + mock.onCommand('wrangler deploy', { + exitCode: 0, + stdout: 'Deployed successfully to https://example.workers.dev' + }) + + return mock +} + +/** + * Create mock execa without default handlers + */ +export function createEmptyMockExeca(): MockExeca { + return new MockExeca() +} + +/** + * Create a complete ProcessRunner mock from a MockExeca instance + * This satisfies the ProcessRunner interface including spawn + */ +export function createMockProcessRunner(mockExeca: MockExeca): import('../../../src/cli/dependencies').ProcessRunner { + return { + exec: async (command: string, args?: string[], options?: import('execa').Options) => { + const result = await mockExeca.execa(command, args ?? [], options ?? {}) as unknown as { + exitCode: number + stdout: string + stderr: string + failed: boolean + killed: boolean + signal?: string + } + return { + exitCode: result.exitCode ?? 0, + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + failed: result.failed ?? false, + killed: result.killed ?? false, + signal: result.signal as string | undefined + } + }, + spawn: (_command: string, _args?: string[], _options?: { cwd?: string; stdio?: unknown; env?: NodeJS.ProcessEnv }) => { + // Return a mock spawned process that does nothing + const events: Record void>> = {} + return { + pid: 12345, + stdout: null, + stderr: null, + killed: false, + kill: () => true, + on(event: string, handler: (arg: unknown) => void) { + if (!events[event]) events[event] = [] + events[event].push(handler) + return this + } + } as import('../../../src/cli/dependencies').SpawnedProcess + } + } +} diff --git a/packages/devflare/tests/integration/mocks/virtual-fs.ts b/packages/devflare/tests/integration/mocks/virtual-fs.ts new file mode 100644 index 0000000..3eabb9a --- /dev/null +++ b/packages/devflare/tests/integration/mocks/virtual-fs.ts @@ -0,0 +1,568 @@ +// ============================================================================= +// Virtual File System โ€” Deep mock for fs/promises +// ============================================================================= + +import type { PathLike, MakeDirectoryOptions, Stats } from 'node:fs' + +/** + * In-memory file system node + */ +interface FSNode { + type: 'file' | 'directory' + content?: string + children?: Map + mtime: Date + mode: number +} + +/** + * Virtual File System for integration testing + * Simulates fs operations in memory + */ +export class VirtualFileSystem { + private root: FSNode = { + type: 'directory', + children: new Map(), + mtime: new Date(), + mode: 0o755 + } + + private cwd: string = '/' + + // Track all operations for assertions + public operations: Array<{ + op: string + path: string + args?: unknown[] + }> = [] + + /** + * Normalize path separators and resolve relative paths + */ + private normalizePath(p: string | PathLike): string { + let pathStr = String(p) + + // Normalize separators + pathStr = pathStr.replace(/\\/g, '/') + + // Handle Windows-style paths (C:\...) + if (/^[A-Za-z]:/.test(pathStr)) { + pathStr = pathStr.substring(2) // Remove drive letter + } + + // Make absolute if relative + if (!pathStr.startsWith('/')) { + pathStr = `${this.cwd}/${pathStr}` + } + + // Resolve . and .. + const parts = pathStr.split('/').filter(Boolean) + const resolved: string[] = [] + + for (const part of parts) { + if (part === '..') { + resolved.pop() + } else if (part !== '.') { + resolved.push(part) + } + } + + return '/' + resolved.join('/') + } + + /** + * Get parts of a path + */ + private getPathParts(p: string): string[] { + return p.split('/').filter(Boolean) + } + + /** + * Navigate to a node, optionally creating directories + */ + private getNode( + p: string, + create: boolean = false + ): FSNode | null { + const parts = this.getPathParts(p) + let current = this.root + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + + if (current.type !== 'directory' || !current.children) { + return null + } + + let child = current.children.get(part) + + if (!child) { + if (create && i < parts.length - 1) { + // Create intermediate directory + child = { + type: 'directory', + children: new Map(), + mtime: new Date(), + mode: 0o755 + } + current.children.set(part, child) + } else if (!create) { + return null + } + } + + if (child) { + current = child + } else { + return null + } + } + + return current + } + + /** + * Get parent directory node + */ + private getParentNode(p: string): { parent: FSNode; name: string } | null { + const parts = this.getPathParts(p) + if (parts.length === 0) return null + + const name = parts.pop()! + const parentPath = '/' + parts.join('/') + + const parent = this.getNode(parentPath) + if (!parent || parent.type !== 'directory') { + return null + } + + return { parent, name } + } + + // ========================================================================= + // fs/promises API Implementation + // ========================================================================= + + async readFile( + path: PathLike, + options?: { encoding?: BufferEncoding } | BufferEncoding + ): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'readFile', path: normalPath }) + + const node = this.getNode(normalPath) + if (!node || node.type !== 'file') { + const error = new Error(`ENOENT: no such file or directory, open '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + const encoding = typeof options === 'string' ? options : options?.encoding + if (encoding) { + return node.content || '' + } + return Buffer.from(node.content || '', 'utf-8') + } + + async writeFile( + path: PathLike, + data: string | Buffer, + _options?: { encoding?: BufferEncoding } + ): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'writeFile', path: normalPath }) + + // Get or create parent directory + const parts = this.getPathParts(normalPath) + const fileName = parts.pop()! + const parentPath = '/' + parts.join('/') + + // Ensure parent exists + await this.mkdir(parentPath, { recursive: true }).catch(() => {}) + + const parentInfo = this.getParentNode(normalPath) + if (!parentInfo) { + const error = new Error(`ENOENT: no such file or directory, open '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + parentInfo.parent.children!.set(fileName, { + type: 'file', + content: typeof data === 'string' ? data : data.toString('utf-8'), + mtime: new Date(), + mode: 0o644 + }) + } + + async mkdir( + path: PathLike, + options?: MakeDirectoryOptions + ): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ + op: 'mkdir', + path: normalPath, + args: [options] + }) + + const parts = this.getPathParts(normalPath) + let current = this.root + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + + if (current.type !== 'directory' || !current.children) { + const error = new Error(`ENOTDIR: not a directory, mkdir '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOTDIR' + throw error + } + + let child = current.children.get(part) + + if (!child) { + if (options?.recursive || i === parts.length - 1) { + child = { + type: 'directory', + children: new Map(), + mtime: new Date(), + mode: 0o755 + } + current.children.set(part, child) + } else { + const error = new Error(`ENOENT: no such file or directory, mkdir '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + } else if (child.type !== 'directory') { + const error = new Error(`EEXIST: file already exists, mkdir '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'EEXIST' + throw error + } + + current = child + } + + return normalPath + } + + async access(path: PathLike, _mode?: number): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'access', path: normalPath }) + + const node = this.getNode(normalPath) + if (!node) { + const error = new Error(`ENOENT: no such file or directory, access '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + } + + async stat(path: PathLike): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'stat', path: normalPath }) + + const node = this.getNode(normalPath) + if (!node) { + const error = new Error(`ENOENT: no such file or directory, stat '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + return { + isFile: () => node.type === 'file', + isDirectory: () => node.type === 'directory', + isBlockDevice: () => false, + isCharacterDevice: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + dev: 0, + ino: 0, + mode: node.mode, + nlink: 1, + uid: 0, + gid: 0, + rdev: 0, + size: node.content?.length || 0, + blksize: 4096, + blocks: 0, + atimeMs: node.mtime.getTime(), + mtimeMs: node.mtime.getTime(), + ctimeMs: node.mtime.getTime(), + birthtimeMs: node.mtime.getTime(), + atime: node.mtime, + mtime: node.mtime, + ctime: node.mtime, + birthtime: node.mtime + } as Stats + } + + async readdir( + path: PathLike, + options?: { withFileTypes?: boolean } + ): Promise boolean; isFile: () => boolean }>> { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'readdir', path: normalPath }) + + const node = this.getNode(normalPath) + if (!node || node.type !== 'directory') { + const error = new Error(`ENOENT: no such file or directory, scandir '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + const entries = Array.from(node.children?.keys() || []) + + if (options?.withFileTypes) { + return entries.map((name) => { + const child = node.children!.get(name)! + return { + name, + isDirectory: () => child.type === 'directory', + isFile: () => child.type === 'file' + } + }) + } + + return entries + } + + async rm( + path: PathLike, + options?: { recursive?: boolean; force?: boolean } + ): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'rm', path: normalPath, args: [options] }) + + const parentInfo = this.getParentNode(normalPath) + if (!parentInfo) { + if (options?.force) return + const error = new Error(`ENOENT: no such file or directory, rm '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + const node = parentInfo.parent.children!.get(parentInfo.name) + if (!node) { + if (options?.force) return + const error = new Error(`ENOENT: no such file or directory, rm '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + if (node.type === 'directory' && !options?.recursive) { + const error = new Error(`EISDIR: illegal operation on a directory, rm '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'EISDIR' + throw error + } + + parentInfo.parent.children!.delete(parentInfo.name) + } + + async unlink(path: PathLike): Promise { + const normalPath = this.normalizePath(path) + this.operations.push({ op: 'unlink', path: normalPath }) + + const parentInfo = this.getParentNode(normalPath) + if (!parentInfo) { + const error = new Error(`ENOENT: no such file or directory, unlink '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + const node = parentInfo.parent.children!.get(parentInfo.name) + if (!node) { + const error = new Error(`ENOENT: no such file or directory, unlink '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + if (node.type !== 'file') { + const error = new Error(`EISDIR: illegal operation on a directory, unlink '${normalPath}'`) + ;(error as NodeJS.ErrnoException).code = 'EISDIR' + throw error + } + + parentInfo.parent.children!.delete(parentInfo.name) + } + + async rename(oldPath: PathLike, newPath: PathLike): Promise { + const normalOld = this.normalizePath(oldPath) + const normalNew = this.normalizePath(newPath) + this.operations.push({ + op: 'rename', + path: normalOld, + args: [normalNew] + }) + + const oldParent = this.getParentNode(normalOld) + if (!oldParent) { + const error = new Error(`ENOENT: no such file or directory, rename '${normalOld}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + const node = oldParent.parent.children!.get(oldParent.name) + if (!node) { + const error = new Error(`ENOENT: no such file or directory, rename '${normalOld}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + // Ensure new parent exists + const newParts = this.getPathParts(normalNew) + const newName = newParts.pop()! + const newParentPath = '/' + newParts.join('/') + await this.mkdir(newParentPath, { recursive: true }).catch(() => {}) + + const newParent = this.getParentNode(normalNew) + if (!newParent) { + const error = new Error(`ENOENT: no such file or directory, rename '${normalNew}'`) + ;(error as NodeJS.ErrnoException).code = 'ENOENT' + throw error + } + + // Move node + oldParent.parent.children!.delete(oldParent.name) + newParent.parent.children!.set(newName, node) + } + + // ========================================================================= + // Utility Methods for Testing + // ========================================================================= + + /** + * Set the current working directory + */ + setCwd(path: string): void { + this.cwd = this.normalizePath(path) + } + + /** + * Pre-populate files for test setup + */ + addFile(path: string, content: string): void { + const normalPath = this.normalizePath(path) + const parts = this.getPathParts(normalPath) + const fileName = parts.pop()! + + // Create parent directories + let current = this.root + for (const part of parts) { + if (!current.children!.has(part)) { + current.children!.set(part, { + type: 'directory', + children: new Map(), + mtime: new Date(), + mode: 0o755 + }) + } + current = current.children!.get(part)! + } + + // Add file + current.children!.set(fileName, { + type: 'file', + content, + mtime: new Date(), + mode: 0o644 + }) + } + + /** + * Check if a file exists (sync, for assertions) + */ + exists(path: string): boolean { + const normalPath = this.normalizePath(path) + return this.getNode(normalPath) !== null + } + + /** + * Get file content (sync, for assertions) + */ + getContent(path: string): string | null { + const normalPath = this.normalizePath(path) + const node = this.getNode(normalPath) + if (!node || node.type !== 'file') return null + return node.content || '' + } + + /** + * List directory contents (sync, for assertions) + */ + list(path: string): string[] { + const normalPath = this.normalizePath(path) + const node = this.getNode(normalPath) + if (!node || node.type !== 'directory') return [] + return Array.from(node.children?.keys() || []) + } + + /** + * Clear all files and reset + */ + reset(): void { + this.root = { + type: 'directory', + children: new Map(), + mtime: new Date(), + mode: 0o755 + } + this.operations = [] + this.cwd = '/' + } + + /** + * Get all recorded operations, optionally filtered + */ + getOperations( + opFilter?: string + ): Array<{ type: string; path: string; args?: unknown[] }> { + const ops = this.operations.map((o) => ({ + type: o.op, + path: o.path, + args: o.args + })) + + if (opFilter) { + return ops.filter((o) => o.type === opFilter) + } + + return ops + } + + /** + * Create the mock module that can replace 'node:fs/promises' + */ + createMock(): typeof import('node:fs/promises') { + return { + readFile: this.readFile.bind(this), + writeFile: this.writeFile.bind(this), + mkdir: this.mkdir.bind(this), + access: this.access.bind(this), + stat: this.stat.bind(this), + readdir: this.readdir.bind(this), + rm: this.rm.bind(this), + unlink: this.unlink.bind(this), + rename: this.rename.bind(this), + // Additional stubs for completeness + lstat: this.stat.bind(this), + realpath: async (p: PathLike) => this.normalizePath(p), + copyFile: async (src: PathLike, dest: PathLike) => { + const content = await this.readFile(src, 'utf-8') + await this.writeFile(dest, content as string) + }, + appendFile: async (path: PathLike, data: string | Buffer) => { + const existing = await this.readFile(path, 'utf-8').catch(() => '') + await this.writeFile(path, (existing as string) + data.toString()) + } + } as unknown as typeof import('node:fs/promises') + } +} + +/** + * Create a virtual file system instance for testing + */ +export function createVirtualFS(): VirtualFileSystem { + return new VirtualFileSystem() +} diff --git a/packages/devflare/tests/integration/package-entry/worker-safe-bundle.test.ts b/packages/devflare/tests/integration/package-entry/worker-safe-bundle.test.ts new file mode 100644 index 0000000..2f720f3 --- /dev/null +++ b/packages/devflare/tests/integration/package-entry/worker-safe-bundle.test.ts @@ -0,0 +1,75 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { cleanupTempDirs, installBuiltDevflare } from '../helpers/built-devflare.helpers' + +const tempDirs: string[] = [] + +interface BuildResult { + success: boolean + logs: Array<{ message?: string }> + outputs: Array<{ path: string }> +} + +const bun = (globalThis as typeof globalThis & { + Bun: { + spawn(args: string[], options: Record): { + stdout: ReadableStream | null + stderr: ReadableStream | null + exited: Promise + } + build(options: Record): Promise + } +}).Bun + +function formatBuildLogs(logs: Array<{ message?: string }>): string { + return logs.map((log) => log.message ?? String(log)).join('\n') +} + +async function createBundleResult(importSource: string, importedNames: string): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'devflare-worker-bundle-')) + tempDirs.push(tempDir) + + await installBuiltDevflare(tempDir) + + await writeFile( + join(tempDir, 'entry.ts'), + `import { ${importedNames} } from '${importSource}'\n` + + `export async function fetch() {\n` + + `\t\treturn new Response(String(Boolean(${importedNames.split(',')[0].trim()})))\n` + + `}\n` + ) + + return await bun.build({ + entrypoints: [join(tempDir, 'entry.ts')], + outdir: join(tempDir, 'out'), + target: 'browser', + conditions: ['browser'], + format: 'esm' + }) +} + +afterAll(async () => { + await cleanupTempDirs(tempDirs) +}) + +describe('worker-safe package entrypoints', () => { + test('main package env import bundles for worker/browser targets', async () => { + const result = await createBundleResult('devflare', 'env') + expect(result.success).toBe(true) + expect(formatBuildLogs(result.logs)).toBe('') + }) + + test('runtime entry exports worker-safe context helpers', async () => { + const result = await createBundleResult('devflare/runtime', 'env, ctx, event, locals, runWithContext, runWithEventContext, getFetchEvent, getQueueEvent, getScheduledEvent, getEmailEvent') + expect(result.success).toBe(true) + expect(formatBuildLogs(result.logs)).toBe('') + }) + + test('runtime entry exports fetch middleware helpers', async () => { + const result = await createBundleResult('devflare/runtime', 'sequence, resolveFetchHandler, invokeFetchHandler, createResolveFetch, invokeFetchModule') + expect(result.success).toBe(true) + expect(formatBuildLogs(result.logs)).toBe('') + }) +}) diff --git a/packages/devflare/tests/integration/test-context/config-autodiscovery.test.ts b/packages/devflare/tests/integration/test-context/config-autodiscovery.test.ts new file mode 100644 index 0000000..5aeaa8a --- /dev/null +++ b/packages/devflare/tests/integration/test-context/config-autodiscovery.test.ts @@ -0,0 +1,378 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'pathe' +import { ensurePackageBuilt } from '../helpers/built-devflare.helpers' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../') +const devflareTestImportPath = pathToFileURL(join(repoRoot, 'src', 'test', 'index.ts')).href +const devflareImportPath = pathToFileURL(join(repoRoot, 'src', 'index.ts')).href +const builtDevflareTestImportPath = pathToFileURL(join(repoRoot, 'dist', 'test', 'index.js')).href +const builtDevflareImportPath = pathToFileURL(join(repoRoot, 'dist', 'index.js')).href +const tempDirs: string[] = [] + +interface TransportResult { + value: number | null + double: number | null + hasDouble: boolean + isDoubleableNumber: boolean +} + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +async function writeProjectFiles(projectDir: string, files: Record): Promise { + for (const [relativePath, content] of Object.entries(files)) { + const absolutePath = join(projectDir, relativePath) + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, content) + } +} + +async function runProjectScript(projectDir: string, scriptRelativePath: string, scriptContents: string): Promise { + const scriptPath = join(projectDir, scriptRelativePath) + await writeProjectFiles(projectDir, { + [scriptRelativePath]: scriptContents + }) + + const process = Bun.spawn(['bun', scriptPath], { + cwd: projectDir, + stdout: 'pipe', + stderr: 'pipe' + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited + ]) + + if (exitCode !== 0) { + throw new Error([ + 'Expected createTestContext() project script to succeed', + stdout.trim(), + stderr.trim() + ].filter(Boolean).join('\n\n')) + } + + return stdout +} + +async function runProjectTests(projectDir: string, testRelativePath: string, testContents: string): Promise { + await writeProjectFiles(projectDir, { + [testRelativePath]: testContents + }) + + const process = Bun.spawn(['bun', 'test', testRelativePath], { + cwd: projectDir, + stdout: 'pipe', + stderr: 'pipe' + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited + ]) + + if (exitCode !== 0) { + throw new Error([ + 'Expected createTestContext() bun test project to succeed', + stdout.trim(), + stderr.trim() + ].filter(Boolean).join('\n\n')) + } + + return [stdout.trim(), stderr.trim()].filter(Boolean).join('\n') +} + +function extractResult(stdout: string): T { + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + for (let index = lines.length - 1;index >= 0;index -= 1) { + const line = lines[index] + if (line.startsWith('RESULT:')) { + return JSON.parse(line.slice('RESULT:'.length)) as T + } + } + + throw new Error(`Expected RESULT line in stdout:\n${stdout}`) +} + +async function createTransportProject(projectDir: string, transportMode: 'auto' | 'disabled'): Promise { + const transportConfig = transportMode === 'disabled' + ? `files: { + transport: null +},` + : '' + + await writeProjectFiles(projectDir, { + 'package.json': JSON.stringify({ + name: `transport-${transportMode}-project`, + private: true, + type: 'module' + }, null, 2), + 'devflare.config.ts': ` +export default { + name: 'transport-${transportMode}-project', + compatibilityDate: '2026-03-17', + ${transportConfig} + bindings: { + durableObjects: { + COUNTER: { className: 'Counter', scriptName: 'do.counter.ts' } + } + } +} +`.trim(), + 'src/DoubleableNumber.ts': ` +export class DoubleableNumber { + value: number + + constructor(n: number) { + this.value = n + } + + get double() { + return this.value * 2 + } +} +`.trim(), + 'src/transport.ts': ` +import { DoubleableNumber } from './DoubleableNumber' + +export const transport = { + DoubleableNumber: { + encode: (value: unknown) => value instanceof DoubleableNumber && value.value, + decode: (value: number) => new DoubleableNumber(value) + } +} +`.trim(), + 'src/do.counter.ts': ` +import { DoubleableNumber } from './DoubleableNumber' + +export class Counter { + private count = 0 + + increment(n: number = 1): DoubleableNumber { + this.count += n + return new DoubleableNumber(this.count) + } +} +`.trim() + }) +} + +describe('createTestContext config autodiscovery', () => { + test('auto-discovers devflare.config.mts when no explicit config path is provided', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-mts-')) + tempDirs.push(projectDir) + + await writeProjectFiles(projectDir, { + 'package.json': JSON.stringify({ + name: 'test-context-mts-project', + private: true, + type: 'module' + }, null, 2), + 'devflare.config.mts': ` +export default { + name: 'test-context-mts-project', + compatibilityDate: '2026-03-17' +} +`.trim() + }) + + const stdout = await runProjectScript(projectDir, 'tests/autodiscovery-script.mjs', ` +import { createTestContext } from '${devflareTestImportPath}' +import { env } from '${devflareImportPath}' + +await createTestContext() +await env.dispose() +console.log('auto-discovered-mts-config') +`) + + expect(stdout.trim()).toContain('auto-discovered-mts-config') + }) + + test('auto-discovers devflare.config.ts from bun test hooks without an explicit config path', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-bun-test-')) + tempDirs.push(projectDir) + + await writeProjectFiles(projectDir, { + 'package.json': JSON.stringify({ + name: 'test-context-bun-test-project', + private: true, + type: 'module' + }, null, 2), + 'devflare.config.ts': ` +export default { + name: 'test-context-bun-test-project', + compatibilityDate: '2026-03-17', + vars: { + TEST_VALUE: 'auto-discovered' + }, + files: { + fetch: 'src/fetch.ts' + } +} +`.trim(), + 'src/fetch.ts': ` +export async function fetch(): Promise { + return new Response('ok') +} +`.trim() + }) + + const output = await runProjectTests(projectDir, 'tests/autodiscovery-bun.test.ts', ` +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext } from '${devflareTestImportPath}' +import { env } from '${devflareImportPath}' + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +test('auto-discovers config during bun test hooks', () => { + expect(env.TEST_VALUE).toBe('auto-discovered') +}) +`) + + expect(output).toContain('1 pass') + }) + + test('auto-discovers devflare.config.ts from bun test hooks when importing the built dist entry', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-built-dist-')) + tempDirs.push(projectDir) + + await ensurePackageBuilt() + + await writeProjectFiles(projectDir, { + 'package.json': JSON.stringify({ + name: 'test-context-built-dist-project', + private: true, + type: 'module' + }, null, 2), + 'devflare.config.ts': ` +export default { + name: 'test-context-built-dist-project', + compatibilityDate: '2026-03-17', + vars: { + TEST_VALUE: 'built-dist-auto-discovered' + }, + files: { + fetch: 'src/fetch.ts' + } +} +`.trim(), + 'src/fetch.ts': ` +export async function fetch(): Promise { + return new Response('ok') +} +`.trim() + }) + + const output = await runProjectTests(projectDir, 'tests/autodiscovery-built-dist.test.ts', ` +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { createTestContext } from '${builtDevflareTestImportPath}' +import { env } from '${builtDevflareImportPath}' + +beforeAll(async () => { + await createTestContext() +}) + +afterAll(async () => { + await env.dispose() +}) + +test('auto-discovers config during bun test hooks from built dist entry', () => { + expect(env.TEST_VALUE).toBe('built-dist-auto-discovered') +}) +`) + + expect(output).toContain('1 pass') + }) + + test('auto-discovers src/transport.ts when files.transport is omitted', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-transport-auto-')) + tempDirs.push(projectDir) + + await createTransportProject(projectDir, 'auto') + + const stdout = await runProjectScript(projectDir, 'tests/transport-autodiscovery-script.mjs', ` +import { createTestContext } from '${devflareTestImportPath}' +import { env } from '${devflareImportPath}' +import { DoubleableNumber } from '../src/DoubleableNumber' + +await createTestContext() + +let summary + +try { + const result = await env.COUNTER.getByName('main').increment(2) + summary = { + value: result?.value ?? null, + double: result && typeof result === 'object' && 'double' in result ? result.double : null, + hasDouble: Boolean(result && typeof result === 'object' && 'double' in result), + isDoubleableNumber: result instanceof DoubleableNumber + } +} finally { + await env.dispose() +} + +console.log('RESULT:' + JSON.stringify(summary)) +`) + + const result = extractResult(stdout) + expect(result.value).toBe(2) + expect(result.double).toBe(4) + expect(result.hasDouble).toBe(true) + expect(result.isDoubleableNumber).toBe(true) + }) + + test('disables conventional transport autodiscovery when files.transport is null', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-transport-null-')) + tempDirs.push(projectDir) + + await createTransportProject(projectDir, 'disabled') + + const stdout = await runProjectScript(projectDir, 'tests/transport-disabled-script.mjs', ` +import { createTestContext } from '${devflareTestImportPath}' +import { env } from '${devflareImportPath}' +import { DoubleableNumber } from '../src/DoubleableNumber' + +await createTestContext() + +let summary + +try { + const result = await env.COUNTER.getByName('main').increment(2) + summary = { + value: result?.value ?? null, + double: result && typeof result === 'object' && 'double' in result ? result.double : null, + hasDouble: Boolean(result && typeof result === 'object' && 'double' in result), + isDoubleableNumber: result instanceof DoubleableNumber + } +} finally { + await env.dispose() +} + +console.log('RESULT:' + JSON.stringify(summary)) +`) + + const result = extractResult(stdout) + expect(result.value).toBe(2) + expect(result.double).toBeNull() + expect(result.hasDouble).toBe(false) + expect(result.isDoubleableNumber).toBe(false) + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/integration/test-context/event-accessors.test.ts b/packages/devflare/tests/integration/test-context/event-accessors.test.ts new file mode 100644 index 0000000..aa3262f --- /dev/null +++ b/packages/devflare/tests/integration/test-context/event-accessors.test.ts @@ -0,0 +1,156 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { join } from 'pathe' +import { dirname } from 'pathe' +import { env } from '../../../src' +import { cf, createTestContext } from '../../../src/test' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../') +const runtimeImportPath = pathToFileURL(join(repoRoot, 'src', 'runtime', 'index.ts')).href +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +describe('createTestContext event accessors', () => { + test('establishes active surface events automatically for worker, queue, scheduled, email, and tail helpers', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-events-')) + tempDirs.push(projectDir) + + const runtimeEnv = env as unknown as { + RESULTS: { + get(key: string): Promise + } + dispose(): Promise + } + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'test-context-event-accessors', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'test-context-event-accessors', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts' + }, + bindings: { + kv: { + RESULTS: 'results-kv-id' + } + }, + triggers: { + crons: ['0 * * * *'] + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), ` +import { getFetchEvent } from '${runtimeImportPath}' +import type { FetchEvent } from '${runtimeImportPath}' + +export async function fetch({ url, request }: FetchEvent) { + const activeEvent = getFetchEvent() + + return Response.json({ + requestUrl: activeEvent.request.url, + eventUrl: activeEvent.url.href, + sameRequest: activeEvent.request === request, + sameUrl: activeEvent.url === url, + safeUrl: getFetchEvent.safe()?.url.href ?? null + }) +} +`.trim()) + await writeFile(join(projectDir, 'src', 'queue.ts'), ` +import { getQueueEvent } from '${runtimeImportPath}' + +export async function queue(event) { + const activeEvent = getQueueEvent() + await event.env.RESULTS.put('queue', event.messages[0].body.value + ':' + activeEvent.batch.queue) + activeEvent.messages[0].ack() +} +`.trim()) + await writeFile(join(projectDir, 'src', 'scheduled.ts'), ` +import { getScheduledEvent } from '${runtimeImportPath}' + +export async function scheduled(event) { + await event.env.RESULTS.put('scheduled', getScheduledEvent().controller.cron || 'missing-cron') +} +`.trim()) + await writeFile(join(projectDir, 'src', 'email.ts'), ` +import { getEmailEvent } from '${runtimeImportPath}' + +export async function email(event) { + await event.env.RESULTS.put('email', event.message.from + '->' + getEmailEvent().to) +} +`.trim()) + await writeFile(join(projectDir, 'src', 'tail.ts'), ` +import { getTailEvent } from '${runtimeImportPath}' + +export async function tail(event) { + await event.env.RESULTS.put('tail', getTailEvent().events[0].scriptName + ':' + event.events.length) +} +`.trim()) + + await createTestContext(join(projectDir, 'devflare.config.ts')) + + try { + const fetchResponse = await cf.worker.get('/inspect') + expect(fetchResponse.status).toBe(200) + const fetchPayload = await fetchResponse.json() as { + requestUrl: string + eventUrl: string + sameRequest: boolean + sameUrl: boolean + safeUrl: string | null + } + expect(fetchPayload).toEqual({ + requestUrl: 'http://localhost/inspect', + eventUrl: 'http://localhost/inspect', + sameRequest: true, + sameUrl: true, + safeUrl: 'http://localhost/inspect' + }) + + const queueResult = await cf.queue.send({ value: 'queued' }) + expect(queueResult.total).toBe(1) + expect(queueResult.acked).toHaveLength(1) + expect(await runtimeEnv.RESULTS.get('queue')).toBe('queued:test-queue') + + const scheduledResult = await cf.scheduled.trigger('0 * * * *') + expect(scheduledResult.success).toBe(true) + expect(await runtimeEnv.RESULTS.get('scheduled')).toBe('0 * * * *') + + const emailResponse = await cf.email.send({ + from: 'sender@example.com', + to: 'worker@example.com', + subject: 'Event accessors', + body: 'Hello from the regression test' + }) + expect(emailResponse.status).toBe(200) + expect(await runtimeEnv.RESULTS.get('email')).toBe('sender@example.com->worker@example.com') + + const tailResult = await cf.tail.trigger([ + { + scriptName: 'tail-worker', + outcome: 'ok', + eventTimestamp: Date.now() + } + ]) + expect(tailResult.success).toBe(true) + expect(await runtimeEnv.RESULTS.get('tail')).toBe('tail-worker:1') + } finally { + await runtimeEnv.dispose() + } + }) +}) diff --git a/packages/devflare/tests/integration/test-context/file-routes.test.ts b/packages/devflare/tests/integration/test-context/file-routes.test.ts new file mode 100644 index 0000000..3c002bb --- /dev/null +++ b/packages/devflare/tests/integration/test-context/file-routes.test.ts @@ -0,0 +1,68 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { env } from '../../../src' +import { cf, createTestContext } from '../../../src/test' + +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +describe('createTestContext file routes', () => { + test('dispatches default src/routes modules and populates params', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-file-routes-test-context-')) + tempDirs.push(projectDir) + + await mkdir(join(projectDir, 'src', 'routes', 'users'), { recursive: true }) + await mkdir(join(projectDir, 'src', 'routes', 'blog'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'file-routes-test-context', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'file-routes-test-context', + compatibilityDate: '2026-03-17' +} +`.trim()) + await writeFile(join(projectDir, 'src', 'routes', 'index.ts'), ` +export async function GET(): Promise { + return new Response('root') +} +`.trim()) + await writeFile(join(projectDir, 'src', 'routes', 'users', '[id].ts'), ` +export async function GET(event): Promise { + return new Response(String(event.params.id)) +} +`.trim()) + await writeFile(join(projectDir, 'src', 'routes', 'blog', '[...slug].ts'), ` +export async function GET(event): Promise { + return new Response(String(event.params.slug)) +} +`.trim()) + + await createTestContext(join(projectDir, 'devflare.config.ts')) + + try { + const rootResponse = await cf.worker.get('/') + expect(rootResponse.status).toBe(200) + expect(await rootResponse.text()).toBe('root') + + const userResponse = await cf.worker.get('/users/42') + expect(userResponse.status).toBe(200) + expect(await userResponse.text()).toBe('42') + + const blogResponse = await cf.worker.get('/blog/a/b') + expect(blogResponse.status).toBe(200) + expect(await blogResponse.text()).toBe('a/b') + } finally { + await env.dispose() + } + }) +}) diff --git a/packages/devflare/tests/integration/test-context/local-bindings-matrix.test.ts b/packages/devflare/tests/integration/test-context/local-bindings-matrix.test.ts new file mode 100644 index 0000000..de9214a --- /dev/null +++ b/packages/devflare/tests/integration/test-context/local-bindings-matrix.test.ts @@ -0,0 +1,235 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { env } from '../../../src' +import { writeLocalSecret } from '../../../src/secrets/local-secrets' +import { cf, createTestContext } from '../../../src/test' + +const tempDirs: string[] = [] +const PNG_1X1_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +async function createMatrixProject(): Promise { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-local-bindings-matrix-')) + tempDirs.push(projectDir) + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'devflare-local-bindings-matrix', + private: true, + type: 'module' + }, + null, + 2 + ) + ) + await writeFile( + join(projectDir, 'devflare.config.ts'), + ` +export default { + name: 'devflare-local-bindings-matrix', + compatibilityDate: '2026-04-27', + files: { + fetch: 'src/fetch.ts', + workflows: 'src/wf.*.ts' + }, + secretsStoreId: 'store-local', + bindings: { + hyperdrive: { + POSTGRES: { + id: 'hyperdrive-local', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + }, + workerLoaders: { + WORKER_LOADER: {} + }, + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow' + } + }, + images: { + IMAGES_SERVICE: true + }, + media: { + MEDIA_SERVICE: true + }, + secretsStore: { + API_TOKEN: 'api-token' + }, + sendEmail: { + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + } +} +`.trim() + ) + await writeFile( + join(projectDir, 'src', 'wf.order.ts'), + ` +import { WorkflowEntrypoint } from 'cloudflare:workers' + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event, step) { + await step.do('record order', async () => ({ + orderId: event.payload.orderId, + total: event.payload.total + })) + } +} +`.trim() + ) + await writeFile( + join(projectDir, 'src', 'fetch.ts'), + ` +const PNG_1X1_BASE64 = '${PNG_1X1_BASE64}' + +function streamFromText(text) { + return new Response(text).body +} + +function streamFromBase64(base64) { + const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)) + return new Response(bytes).body +} + +export default { + async fetch(_request, env, _ctx) { + const workflow = await env.ORDER_WORKFLOW.create({ + id: 'order-1', + params: { orderId: 'order-1', total: 42 } + }) + const workflowStatus = await workflow.status() + + const imageInfo = await env.IMAGES_SERVICE.info(streamFromBase64(PNG_1X1_BASE64)) + const imageTransformer = await env.IMAGES_SERVICE.input(streamFromBase64(PNG_1X1_BASE64)) + const transformedImage = await imageTransformer.transform({ width: 1 }) + const imageOutput = await transformedImage.output({ format: 'image/png' }) + const imageResponse = await imageOutput.response() + + const mediaTransformer = await env.MEDIA_SERVICE.input(streamFromText('local media payload')) + const mediaOutput = await mediaTransformer.output({ format: 'video/mp4' }) + const mediaResponse = await mediaOutput.response() + + await env.EMAIL.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Local binding matrix', + text: 'Sent through the local Send Email binding' + }) + + const loadedWorker = await env.WORKER_LOADER.load({ + compatibilityDate: '2026-04-27', + mainModule: 'worker.js', + modules: { + 'worker.js': { + js: "export default { async fetch() { return new Response('loader-ok') } }" + } + } + }) + const loadedEntrypoint = loadedWorker.getEntrypoint() + const loadedResponse = await loadedEntrypoint.fetch('https://example.com/') + + return Response.json({ + secret: await env.API_TOKEN.get(), + hyperdrive: { + connectionString: env.POSTGRES.connectionString, + database: env.POSTGRES.database + }, + workflow: { + id: workflow.id, + status: workflowStatus.status + }, + images: { + width: imageInfo.width, + contentType: await imageOutput.contentType(), + status: imageResponse.status + }, + media: { + contentType: await mediaOutput.contentType(), + status: mediaResponse.status + }, + workerLoader: { + status: loadedResponse.status, + text: await loadedResponse.text() + }, + email: 'sent' + }) + } +} +`.trim() + ) + + writeLocalSecret({ + cwd: projectDir, + storeId: 'store-local', + name: 'api-token', + value: 'secret-value' + }) + + return projectDir +} + +describe('createTestContext local binding matrix', () => { + test('runs local shims and Miniflare bindings for offline-first Cloudflare features', async () => { + const projectDir = await createMatrixProject() + const runtimeEnv = env as typeof env & { + dispose(): Promise + } + + await createTestContext(join(projectDir, 'devflare.config.ts')) + + try { + const response = await cf.worker.get('/matrix') + expect(response.status).toBe(200) + const payload = await response.json() as { + secret: string + hyperdrive: { connectionString: string; database: string } + workflow: { id: string; status: string } + images: { width: number; contentType: string; status: number } + media: { contentType: string; status: number } + workerLoader: { status: number; text: string } + email: string + } + + expect(payload.secret).toBe('secret-value') + expect(payload.hyperdrive).toEqual({ + connectionString: 'postgres://user:pass@localhost:5432/app', + database: 'app' + }) + expect(payload.workflow.id).toBe('order-1') + expect(['queued', 'running', 'complete', 'waiting']).toContain(payload.workflow.status) + expect(payload.images).toEqual({ + width: 1, + contentType: 'image/png', + status: 200 + }) + expect(payload.media).toEqual({ + contentType: 'video/mp4', + status: 200 + }) + expect(payload.workerLoader).toEqual({ + status: 200, + text: 'loader-ok' + }) + expect(payload.email).toBe('sent') + } finally { + await runtimeEnv.dispose() + } + }) +}) diff --git a/packages/devflare/tests/integration/test-context/local-containers.test.ts b/packages/devflare/tests/integration/test-context/local-containers.test.ts new file mode 100644 index 0000000..b81606b --- /dev/null +++ b/packages/devflare/tests/integration/test-context/local-containers.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { + createContainerManager, + getContainerSkipReason, + stopActiveContainers +} from '../../../src/test' + +const DEFAULT_CONTAINER_IMAGE = 'ghcr.io/microsoft/magentic-ui-python-env:0.0.1' + +describe('local container support', () => { + test('launches a cached image offline and exposes fetch/state/log interaction APIs', async () => { + const skipReason = await getContainerSkipReason({ + engine: (process.env.DEVFLARE_CONTAINER_ENGINE as 'docker' | 'podman' | undefined) ?? 'auto' + }) + + if (skipReason) { + console.warn(`[devflare] skipping local container integration test: ${skipReason}`) + return + } + + const manager = createContainerManager({ + engine: (process.env.DEVFLARE_CONTAINER_ENGINE as 'docker' | 'podman' | undefined) ?? 'auto' + }) + const image = process.env.DEVFLARE_CONTAINER_TEST_IMAGE ?? DEFAULT_CONTAINER_IMAGE + const instance = await manager.start('PythonHttpServer', { + image, + port: 8080, + entrypoint: ['python3'], + command: ['-m', 'http.server', '8080', '--bind', '0.0.0.0'], + offline: true, + readyTimeoutMs: 30_000 + }) + + try { + const response = await instance.fetch('/') + expect(response.status).toBe(200) + expect(await response.text()).toContain('Directory listing') + + const state = await instance.getState() + expect(state.running).toBe(true) + + const logs = await instance.logs() + expect(logs).toContain('Serving HTTP') + } finally { + await instance.destroy() + await stopActiveContainers() + } + }, 60_000) +}) diff --git a/packages/devflare/tests/integration/test-context/send-email-binding.test.ts b/packages/devflare/tests/integration/test-context/send-email-binding.test.ts new file mode 100644 index 0000000..08771b7 --- /dev/null +++ b/packages/devflare/tests/integration/test-context/send-email-binding.test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { env } from '../../../src' +import { createTestContext } from '../../../src/test' + +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +describe('createTestContext sendEmail bindings', () => { + test('supports env.EMAIL.send() through the bridge-backed test context', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-send-email-')) + tempDirs.push(projectDir) + + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'test-context-send-email-project', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'test-context-send-email-project', + compatibilityDate: '2026-03-17', + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + } +} +`.trim()) + + const runtimeEnv = env as unknown as { + EMAIL: { + send(message: { + from: string + to: string + subject: string + text: string + }): Promise + } + dispose(): Promise + } + + await createTestContext(join(projectDir, 'devflare.config.ts')) + + try { + await expect(runtimeEnv.EMAIL.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Bridge send email', + text: 'Hello from the send email binding' + })).resolves.toBeUndefined() + } finally { + await runtimeEnv.dispose() + } + }) +}) diff --git a/packages/devflare/tests/integration/test-context/startup-retry.test.ts b/packages/devflare/tests/integration/test-context/startup-retry.test.ts new file mode 100644 index 0000000..2de6f89 --- /dev/null +++ b/packages/devflare/tests/integration/test-context/startup-retry.test.ts @@ -0,0 +1,72 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { env } from '../../../src' +import { BridgeClient } from '../../../src/bridge' +import { createTestContext } from '../../../src/test' + +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +describe('createTestContext startup retries', () => { + test('retries bridge-backed startup when the first bridge connection fails', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-test-context-retry-')) + tempDirs.push(projectDir) + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'test-context-retry-project', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'test-context-retry-project', + compatibilityDate: '2026-03-17', + bindings: { + kv: { + CACHE: 'cache-kv-id' + } + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), ` +export default { + async fetch() { + return new Response('ok') + } +} +`.trim()) + + const originalConnect = BridgeClient.prototype.connect + let connectAttempts = 0 + + BridgeClient.prototype.connect = async function (this: BridgeClient): Promise { + connectAttempts += 1 + + if (connectAttempts === 1) { + throw new Error('WebSocket connection failed') + } + + return await originalConnect.call(this) + } + + try { + await createTestContext(join(projectDir, 'devflare.config.ts')) + + const envAny = env as any + await envAny.CACHE.put('retry-check', 'ok') + expect(await envAny.CACHE.get('retry-check')).toBe('ok') + expect(connectAttempts).toBe(2) + } finally { + BridgeClient.prototype.connect = originalConnect + await (env as any).dispose() + } + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/integration/vite/config.test.ts b/packages/devflare/tests/integration/vite/config.test.ts new file mode 100644 index 0000000..3fac267 --- /dev/null +++ b/packages/devflare/tests/integration/vite/config.test.ts @@ -0,0 +1,843 @@ +// ============================================================================= +// Vite Plugin Config Hook โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test' +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { compileConfig } from '../../../src/config/compiler' +import type { DevflareConfig, DevflareConfigInput } from '../../../src/config/schema' +import { configSchema } from '../../../src/config/schema' +import { brandAsLocalConfig, type LocalConfig } from '../../../src/config/resolve-phased' +import { resolveViteUserConfig } from '../../../src/vite' +import { devflarePlugin, getPluginContext } from '../../../src/vite/plugin' +import { createTestHarness, createMockProcessRunner, type TestHarness } from '../mocks' +import { setDependencies, clearDependencies } from '../../../src/cli/dependencies' + +/** + * Helper to parse and validate config from input + */ +function parseConfig(input: DevflareConfigInput): LocalConfig { + return brandAsLocalConfig(configSchema.parse(input)) +} + +describe('vite plugin config generation', () => { + let harness: TestHarness + + beforeEach(() => { + harness = createTestHarness({ + cwd: '/project', + emptyExeca: true + }) + + setDependencies({ + fs: harness.fs.createMock(), + exec: createMockProcessRunner(harness.execa) + }) + }) + + afterEach(() => { + harness.reset() + clearDependencies() + }) + + async function withResolvedPluginOutput(options: { + configSource: string + files: Record + assert(projectDir: string): Promise + }): Promise { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-config-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-config-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), options.configSource.trim()) + + for (const [relativePath, content] of Object.entries(options.files)) { + await writeFile(join(projectDir, relativePath), content) + } + + const plugin = devflarePlugin() + if (!plugin.configResolved) { + throw new Error('Expected devflare Vite plugin to expose configResolved()') + } + + await (plugin.configResolved as any)({ + root: projectDir, + command: 'build' + } as any) + + await options.assert(projectDir) + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + } + + describe('compileConfig', () => { + test('compiles minimal config', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01' + } + + const result = compileConfig(parseConfig(config)) + + expect(result.name).toBe('test-worker') + expect(result.compatibility_date).toBe('2024-01-01') + }) + + test('compiles KV bindings configured with explicit ids', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + bindings: { + kv: { + CACHE: { id: 'cache-namespace-id' }, + STORE: { id: 'store-namespace-id' } + } + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.kv_namespaces).toBeDefined() + expect(result.kv_namespaces).toHaveLength(2) + }) + + test('compiles D1 bindings', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + bindings: { + d1: { + DB: { id: 'database-id' } + } + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.d1_databases).toBeDefined() + expect(result.d1_databases).toHaveLength(1) + expect(result.d1_databases![0].binding).toBe('DB') + }) + + test('compiles R2 bindings', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + bindings: { + r2: { + BUCKET: 'bucket-name' + } + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.r2_buckets).toBeDefined() + expect(result.r2_buckets).toHaveLength(1) + }) + + test('compiles Durable Object bindings', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + bindings: { + durableObjects: { + COUNTER: { className: 'Counter' } + } + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.durable_objects).toBeDefined() + expect(result.durable_objects?.bindings).toHaveLength(1) + expect(result.durable_objects?.bindings?.[0].name).toBe('COUNTER') + expect(result.durable_objects?.bindings?.[0].class_name).toBe('Counter') + }) + + test('compiles vars', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + vars: { + API_URL: 'https://api.example.com', + DEBUG: 'true' + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.vars).toBeDefined() + expect(result.vars?.API_URL).toBe('https://api.example.com') + expect(result.vars?.DEBUG).toBe('true') + }) + + test('compiles cron triggers', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + triggers: { + crons: ['0 * * * *', '0 0 * * *'] + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.triggers).toBeDefined() + expect(result.triggers?.crons).toEqual(['0 * * * *', '0 0 * * *']) + }) + + test('compiles with environment override', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + vars: { + API_URL: 'https://api.dev.example.com' + }, + env: { + production: { + vars: { + API_URL: 'https://api.example.com' + } + } + } + } + + const result = compileConfig(parseConfig(config), 'production') + + expect(result.vars?.API_URL).toBe('https://api.example.com') + }) + + test('merges compatibility flags', () => { + const config: DevflareConfigInput = { + name: 'test-worker', + compatibilityDate: '2024-01-01', + compatibilityFlags: ['nodejs_compat'] + } + + const result = compileConfig(parseConfig(config)) + + expect(result.compatibility_flags).toContain('nodejs_compat') + }) + }) + + describe('stringifyConfig', () => { + test('produces valid JSON with header comment', () => { + const { stringifyConfig } = require('../../../src/config/compiler') + + const wranglerConfig = { + name: 'test-worker', + compatibility_date: '2024-01-01' + } + + const content = stringifyConfig(wranglerConfig) + + expect(content).toContain('// Generated by devflare') + expect(content).toContain('test-worker') + }) + + test('formats output as JSON with tabs', () => { + const { stringifyConfig } = require('../../../src/config/compiler') + + const wranglerConfig = { + name: 'test-worker', + compatibility_date: '2024-01-01', + vars: { API_URL: 'https://api.example.com' } + } + + const content = stringifyConfig(wranglerConfig) + + // Remove comment lines and parse + const jsonContent = content + .split('\n') + .filter((line: string) => !line.trim().startsWith('//')) + .join('\n') + + const parsed = JSON.parse(jsonContent) + expect(parsed.name).toBe('test-worker') + expect(parsed.vars.API_URL).toBe('https://api.example.com') + }) + + test('includes all bindings in output', () => { + const { stringifyConfig } = require('../../../src/config/compiler') + + const wranglerConfig = { + name: 'test-worker', + compatibility_date: '2024-01-01', + kv_namespaces: [{ binding: 'CACHE', id: 'cache-ns' }], + d1_databases: [{ binding: 'DB', database_id: 'db-id' }] + } + + const content = stringifyConfig(wranglerConfig) + + expect(content).toContain('kv_namespaces') + expect(content).toContain('CACHE') + expect(content).toContain('d1_databases') + expect(content).toContain('DB') + }) + }) + + describe('config with all bindings', () => { + test('compiles complex config with multiple bindings', () => { + const config: DevflareConfigInput = { + name: 'full-worker', + compatibilityDate: '2024-01-01', + compatibilityFlags: ['nodejs_compat'], + bindings: { + kv: { CACHE: { id: 'cache-ns' } }, + d1: { DB: { id: 'database-id' } }, + r2: { BUCKET: 'bucket-name' }, + durableObjects: { COUNTER: { className: 'Counter' } }, + services: { AUTH: { service: 'auth-worker' } } + }, + vars: { + API_URL: 'https://api.example.com' + }, + triggers: { + crons: ['0 * * * *'] + } + } + + const result = compileConfig(parseConfig(config)) + + expect(result.name).toBe('full-worker') + expect(result.kv_namespaces).toHaveLength(1) + expect(result.d1_databases).toHaveLength(1) + expect(result.r2_buckets).toHaveLength(1) + expect(result.durable_objects?.bindings).toHaveLength(1) + expect(result.services).toHaveLength(1) + expect(result.vars?.API_URL).toBe('https://api.example.com') + expect(result.triggers?.crons).toHaveLength(1) + }) + }) + + describe('plugin configResolved output', () => { + test('preserves a direct fetch entry for build-mode wrangler output', async () => { + await withResolvedPluginOutput({ + configSource: [ + 'export default {', + "\tname: 'vite-config-test',", + "\tcompatibilityDate: '2026-03-17',", + '\tfiles: {', + "\t\tfetch: 'src/fetch.ts'", + '\t}', + '}' + ].join('\n'), + files: { + 'src/fetch.ts': `export async function fetch(): Promise { return new Response('ok') }` + }, + assert: async (projectDir) => { + const wranglerConfig = await readFile(join(projectDir, '.devflare', 'wrangler.jsonc'), 'utf8') + expect(wranglerConfig).toContain('"main": "../src/fetch.ts"') + await expect(access(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'))).rejects.toThrow() + } + }) + }) + + test('preserves a direct fetch entry while retaining auxiliary bindings in build mode', async () => { + await withResolvedPluginOutput({ + configSource: [ + 'export default {', + "\tname: 'vite-config-test',", + "\tcompatibilityDate: '2026-03-17',", + '\tfiles: {', + "\t\tfetch: 'src/fetch.ts',", + "\t\tqueue: 'src/queue.ts',", + "\t\tscheduled: 'src/scheduled.ts',", + "\t\temail: 'src/email.ts'", + '\t},', + '\tbindings: {', + '\t\tqueues: {', + '\t\t\tproducers: {', + "\t\t\t\tTASK_QUEUE: 'task-queue'", + '\t\t\t},', + '\t\t\tconsumers: [', + '\t\t\t\t{', + "\t\t\t\t\tqueue: 'task-queue'", + '\t\t\t\t}', + '\t\t\t]', + '\t\t}', + '\t},', + '\ttriggers: {', + "\t\tcrons: ['0 * * * *']", + '\t}', + '}' + ].join('\n'), + files: { + 'src/fetch.ts': `export async function fetch(): Promise { return new Response('ok') }`, + 'src/queue.ts': `export async function queue(): Promise { return undefined }`, + 'src/scheduled.ts': `export async function scheduled(): Promise { return undefined }`, + 'src/email.ts': `export async function email() { return undefined }` + }, + assert: async (projectDir) => { + const wranglerConfig = await readFile(join(projectDir, '.devflare', 'wrangler.jsonc'), 'utf8') + expect(wranglerConfig).toContain('"main": "../src/fetch.ts"') + expect(wranglerConfig).toContain('"binding": "TASK_QUEUE"') + expect(wranglerConfig).toContain('"queue": "task-queue"') + expect(wranglerConfig).toContain('"crons": [') + await expect(access(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'))).rejects.toThrow() + } + }) + }) + + test('preserves an explicit wrangler passthrough main instead of generating a composed entry', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-config-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-config-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'vite-config-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + queue: 'src/queue.ts', + scheduled: 'src/scheduled.ts', + email: 'src/email.ts' + }, + wrangler: { + passthrough: { + main: 'src/custom-main.ts' + } + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), `export async function fetch(): Promise { return new Response('ok') }`) + await writeFile(join(projectDir, 'src', 'queue.ts'), `export async function queue(): Promise { return undefined }`) + await writeFile(join(projectDir, 'src', 'scheduled.ts'), `export async function scheduled(): Promise { return undefined }`) + await writeFile(join(projectDir, 'src', 'email.ts'), `export async function email() { return undefined }`) + await writeFile(join(projectDir, 'src', 'custom-main.ts'), `export async function fetch(): Promise { return new Response('custom') }`) + + const plugin = devflarePlugin() + if (!plugin.configResolved) { + throw new Error('Expected devflare Vite plugin to expose configResolved()') + } + + await (plugin.configResolved as any)({ + root: projectDir, + command: 'build' + } as any) + + const wranglerConfig = await readFile(join(projectDir, '.devflare', 'wrangler.jsonc'), 'utf8') + expect(wranglerConfig).toContain('"main": "../src/custom-main.ts"') + await expect(access(join(projectDir, '.devflare', 'worker-entrypoints', 'main.ts'))).rejects.toThrow() + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('clears stale Durable Object plugin context when a later config disables DO discovery', async () => { + const firstProjectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-config-')) + const secondProjectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-config-')) + + try { + await mkdir(join(firstProjectDir, 'src'), { recursive: true }) + await writeFile(join(firstProjectDir, 'package.json'), JSON.stringify({ + name: 'vite-do-config-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(firstProjectDir, 'devflare.config.ts'), ` +export default { + name: 'vite-do-config-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + durableObjects: { + COUNTER: { + className: 'Counter' + } + } + } +} +`.trim()) + await writeFile(join(firstProjectDir, 'src', 'fetch.ts'), `export async function fetch(): Promise { return new Response('ok') }`) + await writeFile(join(firstProjectDir, 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject {} +`.trim()) + + const firstPlugin = devflarePlugin() + if (!firstPlugin.configResolved) { + throw new Error('Expected devflare Vite plugin to expose configResolved()') + } + + await (firstPlugin.configResolved as any)({ + root: firstProjectDir, + command: 'build' + } as any) + + expect(getPluginContext().auxiliaryWorkerConfig).not.toBeNull() + expect(getPluginContext().durableObjects?.files.size).toBe(1) + + await mkdir(join(secondProjectDir, 'src'), { recursive: true }) + await writeFile(join(secondProjectDir, 'package.json'), JSON.stringify({ + name: 'vite-no-do-config-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(secondProjectDir, 'devflare.config.ts'), ` +export default { + name: 'vite-no-do-config-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts', + durableObjects: false + } +} +`.trim()) + await writeFile(join(secondProjectDir, 'src', 'fetch.ts'), `export async function fetch(): Promise { return new Response('ok') }`) + + const secondPlugin = devflarePlugin() + if (!secondPlugin.configResolved) { + throw new Error('Expected devflare Vite plugin to expose configResolved()') + } + + await (secondPlugin.configResolved as any)({ + root: secondProjectDir, + command: 'build' + } as any) + + const pluginContext = getPluginContext() + expect(pluginContext.auxiliaryWorkerConfig).toBeNull() + expect(pluginContext.durableObjects).toBeNull() + } finally { + await rm(firstProjectDir, { recursive: true, force: true }) + await rm(secondProjectDir, { recursive: true, force: true }) + } + }) + + test('exposes ref service bindings as auxiliary workers in serve mode', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-ref-services-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await mkdir(join(projectDir, 'api', 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-ref-services-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'api', 'src', 'ep.api.ts'), ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class ApiEntrypoint extends WorkerEntrypoint { + async ping(): Promise { + return 'PONG' + } +} +`.trim()) + await writeFile(join(projectDir, 'api', 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async ping(): Promise { + return 'DO_PONG' + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), `export async function fetch(): Promise { return new Response('ok') }`) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +const apiConfig = { + name: 'api-worker', + compatibilityDate: '2026-04-28', + files: { + fetch: false, + entrypoints: 'src/ep.*.ts', + durableObjects: 'src/do.*.ts' + }, + bindings: { + d1: { + DB: { name: 'api-db' } + }, + durableObjects: { + COUNTER: 'Counter' + } + } +} + +const resolved = { + name: apiConfig.name, + config: apiConfig, + configPath: './api/devflare.config.ts' +} + +const apiRef = { + get name() { + return resolved.name + }, + get config() { + return resolved.config + }, + get configPath() { + return resolved.configPath + }, + __import: async () => ({ default: apiConfig }), + resolve: async () => resolved +} + +export default { + name: 'site-worker', + compatibilityDate: '2026-04-28', + files: { + fetch: 'src/fetch.ts' + }, + bindings: { + services: { + API: { + service: 'api-worker', + entrypoint: 'ApiEntrypoint', + __ref: apiRef + } + } + } +} +`.trim()) + + const plugin = devflarePlugin() + if (!plugin.configResolved || !plugin.resolveId || !plugin.load) { + throw new Error('Expected devflare Vite plugin to expose configResolved(), resolveId(), and load()') + } + + await (plugin.configResolved as any)({ + root: projectDir, + command: 'serve' + } as any) + + const pluginContext = getPluginContext() + const auxiliaryConfigs = pluginContext.auxiliaryWorkerConfigs.map((worker) => worker.config) + const apiWorkerConfig = auxiliaryConfigs.find((config) => config.name === 'api-worker') as Record + const doWorkerConfig = auxiliaryConfigs.find((config) => config.name === 'api-worker-durable-objects') as Record + + expect(apiWorkerConfig).toBeDefined() + expect(doWorkerConfig).toBeDefined() + expect(apiWorkerConfig.services).toEqual(undefined) + expect(apiWorkerConfig.d1_databases?.[0]?.database_id).toBe('api-db') + expect(apiWorkerConfig.durable_objects?.bindings).toEqual([{ + name: 'COUNTER', + class_name: 'Counter', + script_name: 'api-worker-durable-objects' + }]) + expect(doWorkerConfig.durable_objects?.bindings).toEqual([{ + name: 'COUNTER', + class_name: 'Counter' + }]) + + const resolvedId = await (plugin.resolveId as any)(apiWorkerConfig.main) + const source = await (plugin.load as any)(resolvedId) + expect(source).toContain('ApiEntrypoint') + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) + }) + + describe('plugin configureServer config watching', () => { + test('watches the resolved devflare.config.mts path in serve mode', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-config-watch-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-config-watch-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.mts'), ` +export default { + name: 'vite-config-watch-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } +} +`.trim()) + await writeFile(join(projectDir, 'src', 'fetch.ts'), `export async function fetch(): Promise { return new Response('ok') }`) + + const addedPaths: string[] = [] + const changeHandlers: Array<(changedPath: string) => unknown> = [] + const plugin = devflarePlugin() + + if (!plugin.configResolved || !plugin.configureServer) { + throw new Error('Expected devflare Vite plugin to expose configResolved() and configureServer()') + } + + await (plugin.configResolved as any)({ + root: projectDir, + command: 'serve' + } as any) + + ;(plugin.configureServer as any)({ + watcher: { + add(path: string) { + addedPaths.push(path) + }, + on(event: string, handler: (changedPath: string) => unknown) { + if (event === 'change') { + changeHandlers.push(handler) + } + } + }, + ws: { + send: mock(() => { }) + } + } as any) + + expect(changeHandlers).toHaveLength(1) + expect(addedPaths).toContain(join(projectDir, 'devflare.config.mts')) + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) + }) + + describe('resolveViteUserConfig', () => { + test('merges local vite.config with devflare vite config and injects devflarePlugin', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-resolve-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +const inlinePlugin = { + name: 'inline-plugin' +} + +export default { + name: 'vite-resolve-test', + compatibilityDate: '2026-03-17', + vite: { + define: { + __INLINE__: ${JSON.stringify(JSON.stringify('yes'))} + }, + resolve: { + alias: { + inline: '/inline' + } + }, + plugins: [inlinePlugin] + } +} +`.trim()) + await writeFile(join(projectDir, 'vite.config.ts'), ` +const localPlugin = { + name: 'local-plugin' +} + +export default { + resolve: { + alias: { + local: '/local' + } + }, + plugins: [localPlugin] +} +`.trim()) + + const resolvedConfig = await resolveViteUserConfig({ + command: 'build', + mode: 'production' + } as any, { + cwd: projectDir, + localConfigPath: join(projectDir, 'vite.config.ts') + }) + + expect(resolvedConfig.root).toBe(projectDir) + expect((resolvedConfig.resolve as Record)?.alias).toMatchObject({ + local: '/local', + inline: '/inline' + }) + expect((resolvedConfig.define as Record)?.__INLINE__).toBe(JSON.stringify('yes')) + + const pluginNames = (resolvedConfig.plugins as Array<{ name?: string }> | undefined)?.map((plugin) => plugin.name) + expect(pluginNames).toContain('devflare') + expect(pluginNames).toContain('local-plugin') + expect(pluginNames).toContain('inline-plugin') + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) + + test('preserves promise-like plugin entries when injecting devflarePlugin', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-async-')) + + try { + await mkdir(join(projectDir, 'src'), { recursive: true }) + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'vite-resolve-async-test', + private: true, + type: 'module' + }, null, 2)) + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'vite-resolve-async-test', + compatibilityDate: '2026-03-17' +} +`.trim()) + await writeFile(join(projectDir, 'vite.config.ts'), ` +const localPlugin = { + name: 'local-plugin' +} + +const asyncPlugin = Promise.resolve({ + name: 'async-plugin' +}) + +export default { + plugins: [localPlugin, asyncPlugin] +} +`.trim()) + + const resolvedConfig = await resolveViteUserConfig({ + command: 'build', + mode: 'production' + } as any, { + cwd: projectDir, + localConfigPath: join(projectDir, 'vite.config.ts') + }) + + const pluginEntries = (resolvedConfig.plugins ?? []) as Array + expect(pluginEntries.some((plugin) => typeof (plugin as PromiseLike)?.then === 'function')).toBe(true) + + const resolvedPlugins = await Promise.all(pluginEntries.map(async (plugin) => { + if (typeof (plugin as PromiseLike)?.then === 'function') { + return await plugin as unknown + } + + return plugin + })) + + const pluginNames = resolvedPlugins + .flatMap((plugin) => Array.isArray(plugin) ? plugin : [plugin]) + .filter((plugin): plugin is { name?: string } => typeof plugin === 'object' && plugin !== null) + .map((plugin) => plugin.name) + + expect(pluginNames).toContain('devflare') + expect(pluginNames).toContain('local-plugin') + expect(pluginNames).toContain('async-plugin') + } finally { + await rm(projectDir, { recursive: true, force: true }) + } + }) + }) +}) diff --git a/packages/devflare/tests/integration/vite/transform.test.ts b/packages/devflare/tests/integration/vite/transform.test.ts new file mode 100644 index 0000000..66b350f --- /dev/null +++ b/packages/devflare/tests/integration/vite/transform.test.ts @@ -0,0 +1,263 @@ +// ============================================================================= +// Vite Plugin Transform Hook โ€” Integration Tests +// ============================================================================= + +import { describe, expect, test, beforeEach } from 'bun:test' +import { devflarePlugin } from '../../../src/vite/plugin' +import type { Plugin, TransformResult } from 'vite' + +/** Transform function signature - uses unknown for this context since we don't use it */ +type TransformFn = ( + this: unknown, + code: string, + id: string, + options?: { ssr?: boolean } +) => Promise | TransformResult | undefined + +/** + * Helper to get the transform function from a Vite plugin. + * Handles both function and object-with-handler forms. + */ +function getTransformFn(transform: Plugin['transform']): TransformFn | null { + if (!transform) return null + if (typeof transform === 'function') return transform as TransformFn + if ('handler' in transform) return transform.handler as TransformFn + return null +} + +/** Mock context for testing - transform doesn't use `this` in our implementation */ +const mockContext = null + +describe('vite plugin transform hook', () => { + let plugin: Plugin + let transformFn: TransformFn + + beforeEach(() => { + plugin = devflarePlugin({ doTransforms: true }) + const fn = getTransformFn(plugin.transform) + if (!fn) throw new Error('Plugin transform not found') + transformFn = fn + }) + + describe('file filtering', () => { + test('returns null for non-typescript files', async () => { + const result = await transformFn.call( + mockContext, + 'export const x = 1', + '/project/src/index.js', + {} + ) + + expect(result).toBeNull() + }) + + test('returns null for node_modules files', async () => { + const code = ` + import { DurableObject } from 'cloudflare:workers' + export class MyDO extends DurableObject {} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/node_modules/@cloudflare/workers-types/index.ts', + {} + ) + + expect(result).toBeNull() + }) + + test('returns null for code without DurableObject', async () => { + const result = await transformFn.call( + mockContext, + 'export const x = 1', + '/project/src/utils.ts', + {} + ) + + expect(result).toBeNull() + }) + + test('processes .ts files with DurableObject', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + private count = 0 + + async increment() { + return ++this.count + } +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/do/counter.ts', + {} + ) + + expect(result).not.toBeNull() + expect(typeof result === 'object' && result !== null && 'code' in result).toBe(true) + }) + + test('processes .tsx files with DurableObject', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class StateDO extends DurableObject { + private state = {} +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/do/state.tsx', + {} + ) + + expect(result).not.toBeNull() + }) + }) + + describe('durable object transformation', () => { + test('wraps DO class with context injection', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + private count = 0 + + async increment() { + return ++this.count + } +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/counter.ts', + {} + ) + + expect(result).not.toBeNull() + const output = typeof result === 'object' && result !== null && 'code' in result + ? (result as { code: string }).code + : '' + + // Should contain wrapper with actual naming pattern + expect(output).toContain('CounterWrapper') + expect(output).toContain('__OriginalCounter') + expect(output).toContain('createDurableObjectFetchEvent') + expect(output).toContain('runWithEventContext') + }) + + test('handles multiple DO classes', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async increment() {} +} + +export class Timer extends DurableObject { + async start() {} +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/objects.ts', + {} + ) + + expect(result).not.toBeNull() + const output = typeof result === 'object' && result !== null && 'code' in result + ? (result as { code: string }).code + : '' + + // Both should be wrapped with actual naming pattern + expect(output).toContain('CounterWrapper') + expect(output).toContain('TimerWrapper') + expect(output).toContain('__OriginalCounter') + expect(output).toContain('__OriginalTimer') + }) + + test('preserves source maps', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async increment() { return 1 } +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/counter.ts', + {} + ) + + expect(result).not.toBeNull() + if (typeof result === 'object' && result !== null && 'map' in result) { + expect((result as { map: unknown }).map).toBeDefined() + } + }) + }) + + describe('doTransforms disabled', () => { + test('returns null when doTransforms is false', async () => { + const disabledPlugin = devflarePlugin({ doTransforms: false }) + const disabledTransformFn = getTransformFn(disabledPlugin.transform) + if (!disabledTransformFn) throw new Error('Plugin transform not found') + + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject {} + ` + + const result = await disabledTransformFn.call( + mockContext, + code, + '/project/src/counter.ts', + {} + ) + + expect(result).toBeNull() + }) + }) + + describe('decorator detection', () => { + test('detects @durableObject decorator', async () => { + const code = ` +import { durableObject } from 'devflare' + +@durableObject() +export class Counter { + private count = 0 + + async increment() { + return ++this.count + } +} + ` + + const result = await transformFn.call( + mockContext, + code, + '/project/src/counter.ts', + {} + ) + + // Should at least detect the code contains @durableObject + // Full decorator support will be added in Phase C + expect(result !== undefined).toBe(true) + }) + }) +}) diff --git a/packages/devflare/tests/tsconfig.json b/packages/devflare/tests/tsconfig.json new file mode 100644 index 0000000..c7049d0 --- /dev/null +++ b/packages/devflare/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["./**/*.ts", "../src/**/*.ts"], + "exclude": ["../node_modules", "../dist"] +} diff --git a/packages/devflare/tests/unit/bridge/client-websocket.test.ts b/packages/devflare/tests/unit/bridge/client-websocket.test.ts new file mode 100644 index 0000000..8ef6833 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/client-websocket.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'bun:test' +import { resolveBridgeWebSocketConstructor } from '../../../src/bridge/client' + +describe('resolveBridgeWebSocketConstructor', () => { + test('falls back to ws when the runtime does not expose a global WebSocket', async () => { + const constructor = await resolveBridgeWebSocketConstructor(undefined) + expect(typeof constructor).toBe('function') + }) +}) diff --git a/packages/devflare/tests/unit/bridge/client.test.ts b/packages/devflare/tests/unit/bridge/client.test.ts new file mode 100644 index 0000000..3fd6d7b --- /dev/null +++ b/packages/devflare/tests/unit/bridge/client.test.ts @@ -0,0 +1,221 @@ +// ============================================================================= +// BridgeClient โ€” createWsProxy await + disconnect cleanup tests +// ============================================================================= + +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { BridgeClient } from '../../../src/bridge/client' +import { stringifyJsonMsg } from '../../../src/bridge/v2/wire' + +// ----------------------------------------------------------------------------- +// Fake WebSocket used as a replacement for the global WebSocket constructor. +// ----------------------------------------------------------------------------- + +interface Sent { + data: string | ArrayBuffer | ArrayBufferView +} + +class FakeWebSocket { + static instances: FakeWebSocket[] = [] + + url: string + binaryType: 'arraybuffer' | 'blob' = 'blob' + readyState = 0 + sent: Sent[] = [] + + onopen: ((ev?: unknown) => void) | null = null + onerror: ((ev?: unknown) => void) | null = null + onclose: ((ev?: unknown) => void) | null = null + onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null = null + + constructor(url: string) { + this.url = url + FakeWebSocket.instances.push(this) + } + + send(data: string | ArrayBuffer | ArrayBufferView): void { + this.sent.push({ data }) + } + + close(): void { + this.readyState = 3 + this.onclose?.() + } + + // Test helpers + open(): void { + this.readyState = 1 + this.onopen?.() + } + + emitJson(msg: unknown): void { + this.onmessage?.({ data: stringifyJsonMsg(msg as never) }) + } + + emitRaw(data: string): void { + this.onmessage?.({ data }) + } +} + +let originalWebSocket: typeof globalThis.WebSocket + +beforeEach(() => { + originalWebSocket = globalThis.WebSocket + ; (globalThis as unknown as { WebSocket: unknown }).WebSocket = + FakeWebSocket as unknown as typeof WebSocket + FakeWebSocket.instances = [] +}) + +afterEach(() => { + ; (globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = + originalWebSocket +}) + +function lastSentJson(ws: FakeWebSocket): Record { + const last = ws.sent[ws.sent.length - 1] + if (typeof last.data !== 'string') throw new Error('expected string frame') + return JSON.parse(last.data) +} + +// ----------------------------------------------------------------------------- +// createWsProxy โ€” awaits ws.opened +// ----------------------------------------------------------------------------- + +describe('BridgeClient.createWsProxy', () => { + test('does not resolve until ws.opened arrives', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + let resolved = false + const proxyPromise = client + .createWsProxy('MY_DO', 'abc', 'ws://do/chat', []) + .then((proxy) => { + resolved = true + return proxy + }) + + // Yield a few microtasks/macrotasks; the promise must still be pending + await new Promise((r) => setTimeout(r, 10)) + expect(resolved).toBe(false) + + // The client should have sent a ws.open message with a wid + const openMsg = ws.sent + .map((s) => (typeof s.data === 'string' ? JSON.parse(s.data) : null)) + .find((m) => m && m.t === 'ws.open') + expect(openMsg).toBeTruthy() + const wid = openMsg.wid as number + + // Emit the ws.opened reply + ws.emitJson({ t: 'ws.opened', wid }) + + const proxy = await proxyPromise + expect(resolved).toBe(true) + expect(proxy.wid).toBe(wid) + + client.disconnect() + }) + + test('rejects the pending open when the client disconnects', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + const proxyPromise = client.createWsProxy('MY_DO', 'abc', 'ws://do/chat', []) + + // Flush microtasks so the ws.open send occurs + await Promise.resolve() + + client.disconnect() + + await expect(proxyPromise).rejects.toThrow(/disconnected/i) + }) +}) + +// ----------------------------------------------------------------------------- +// disconnect cleanup โ€” pending RPCs and streams +// ----------------------------------------------------------------------------- + +describe('BridgeClient.disconnect cleanup', () => { + test('rejects pending RPC calls with a clear error', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + const callPromise = client.call('MY_KV.get', ['k']) + // Allow call() to finish serialization and register the pending entry + await new Promise((r) => setTimeout(r, 5)) + + client.disconnect() + + await expect(callPromise).rejects.toThrow(/disconnected/i) + }) + + test('close() is an alias for disconnect()', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + const callPromise = client.call('MY_KV.get', ['k']) + await new Promise((r) => setTimeout(r, 5)) + + client.close() + + await expect(callPromise).rejects.toThrow(/disconnected/i) + expect(client.connected).toBe(false) + }) + + test('errors active readable streams on disconnect', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + const stream = client.createReadableStream(42) + const reader = stream.getReader() + // Trigger pull so activeStreams registration is fully realized + const readPromise = reader.read() + + // Give the pull a tick to register, then disconnect + await new Promise((r) => setTimeout(r, 5)) + client.disconnect() + + await expect(readPromise).rejects.toThrow(/disconnected/i) + }) +}) + +// ----------------------------------------------------------------------------- +// Parse error routing +// ----------------------------------------------------------------------------- + +describe('BridgeClient parse errors', () => { + test('logs malformed JSON frames via console.error', async () => { + const client = new BridgeClient({ autoReconnect: false }) + const connectPromise = client.connect() + const ws = FakeWebSocket.instances[0] + ws.open() + await connectPromise + + const spy = mock(() => { }) + const originalError = console.error + console.error = spy as unknown as typeof console.error + + try { + ws.emitRaw('{not json') + expect(spy).toHaveBeenCalled() + const first = spy.mock.calls[0] as unknown[] + expect(String(first[0])).toContain('[devflare bridge client] parse error:') + } finally { + console.error = originalError + client.disconnect() + } + }) +}) diff --git a/packages/devflare/tests/unit/bridge/log.test.ts b/packages/devflare/tests/unit/bridge/log.test.ts new file mode 100644 index 0000000..f22268a --- /dev/null +++ b/packages/devflare/tests/unit/bridge/log.test.ts @@ -0,0 +1,50 @@ +// ============================================================================= +// Bridge Log โ€” Debug-gated logger tests +// ============================================================================= + +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { bridgeLog } from '../../../src/bridge/log' + +describe('bridgeLog', () => { + const originalDebug = process.env.DEVFLARE_DEBUG_BRIDGE + let warnSpy: ReturnType + let debugSpy: ReturnType + let originalWarn: typeof console.warn + let originalDebugFn: typeof console.debug + + beforeEach(() => { + warnSpy = mock(() => {}) + debugSpy = mock(() => {}) + originalWarn = console.warn + originalDebugFn = console.debug + console.warn = warnSpy as unknown as typeof console.warn + console.debug = debugSpy as unknown as typeof console.debug + }) + + afterEach(() => { + console.warn = originalWarn + console.debug = originalDebugFn + if (originalDebug === undefined) delete process.env.DEVFLARE_DEBUG_BRIDGE + else process.env.DEVFLARE_DEBUG_BRIDGE = originalDebug + }) + + test('stays silent when DEVFLARE_DEBUG_BRIDGE is unset', () => { + delete process.env.DEVFLARE_DEBUG_BRIDGE + bridgeLog.warn('should be dropped', new Error('boom')) + bridgeLog.debug('should be dropped') + expect(warnSpy).not.toHaveBeenCalled() + expect(debugSpy).not.toHaveBeenCalled() + }) + + test('emits warn and debug when DEVFLARE_DEBUG_BRIDGE is enabled', () => { + process.env.DEVFLARE_DEBUG_BRIDGE = '1' + const err = new Error('boom') + bridgeLog.warn('hello', err) + bridgeLog.debug('hi') + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(debugSpy).toHaveBeenCalledTimes(1) + const [warnMessage, warnError] = warnSpy.mock.calls[0] + expect(warnMessage).toContain('hello') + expect(warnError).toBe(err) + }) +}) diff --git a/packages/devflare/tests/unit/bridge/miniflare-dispose.test.ts b/packages/devflare/tests/unit/bridge/miniflare-dispose.test.ts new file mode 100644 index 0000000..908b2b1 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/miniflare-dispose.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' +import { + createMiniflareInstanceHandle, + isIgnorableMiniflareDisposeError +} from '../../../src/bridge/miniflare' + +describe('Miniflare instance disposal', () => { + test('treats already-closed process handles as ignorable dispose errors', () => { + const error = Object.assign(new Error('bad file descriptor, kill'), { + code: 'EBADF', + syscall: 'kill' + }) + + expect(isIgnorableMiniflareDisposeError(error)).toBe(true) + }) + + test('does not hide unrelated dispose errors', () => { + const error = Object.assign(new Error('permission denied, kill'), { + code: 'EACCES', + syscall: 'kill' + }) + + expect(isIgnorableMiniflareDisposeError(error)).toBe(false) + }) + + test('allows startup when optional direct-access helpers are missing', async () => { + const handle = createMiniflareInstanceHandle({ + async dispose() { }, + async getBindings() { + return { API_TOKEN: 'secret' } + }, + dispatchFetch() { + return Promise.resolve(new Response('ok')) + } + } as never) + + await expect(handle.getBindings()).resolves.toEqual({ API_TOKEN: 'secret' }) + await expect(handle.getKVNamespace('CACHE')).rejects.toThrow( + 'Miniflare runtime does not expose getKVNamespace' + ) + }) + + test('allows cleanup when the runtime does not expose dispose', async () => { + const handle = createMiniflareInstanceHandle({ + async getBindings() { + return { API_TOKEN: 'secret' } + } + } as never) + + await expect(handle.dispose()).resolves.toBeUndefined() + }) + + test('reads bindings from the named primary worker when one is known', async () => { + let requestedWorkerName: string | undefined + const handle = createMiniflareInstanceHandle({ + async dispose() { }, + async getBindings(workerName?: string) { + requestedWorkerName = workerName + return { API_TOKEN: 'secret' } + } + } as never, 'devflare-gateway') + + await expect(handle.getBindings()).resolves.toEqual({ API_TOKEN: 'secret' }) + expect(requestedWorkerName).toBe('devflare-gateway') + }) + + test('merges node-side binding overrides into getBindings results', async () => { + const handle = createMiniflareInstanceHandle({ + async dispose() { }, + async getBindings() { + return { EXISTING: 'value' } + } + } as never, undefined, { + API_TOKEN: { + async get() { + return 'local-secret' + } + } + }) + + const bindings = await handle.getBindings() + expect(bindings.EXISTING).toBe('value') + expect(await (bindings.API_TOKEN as { get(): Promise }).get()).toBe('local-secret') + }) +}) diff --git a/packages/devflare/tests/unit/bridge/proxy.test.ts b/packages/devflare/tests/unit/bridge/proxy.test.ts new file mode 100644 index 0000000..09145d8 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/proxy.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import { createEnvProxy } from '../../../src/bridge/proxy' + +function textResponsePayload(text: string, init: ResponseInit = {}) { + return { + status: init.status ?? 200, + statusText: init.statusText ?? '', + headers: Object.entries(init.headers ?? {}), + body: { + type: 'bytes', + data: btoa(text) + } + } +} + +describe('createEnvProxy service bindings', () => { + test('routes service fetch through the namespaced bridge operation', async () => { + const calls: Array<{ method: string; params: unknown[] }> = [] + const client = { + async call(method: string, params: unknown[]) { + calls.push({ method, params }) + return textResponsePayload('service-ok', { + status: 201, + headers: { 'x-service': 'ok' } + }) + } + } + + const env = createEnvProxy({ + client: client as never, + hints: { API: 'service' } + }) + + const response = await (env.API as Fetcher).fetch( + new Request('https://api.local/auth/magic-link/request', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'creator@example.com' }) + }) + ) + + expect(response.status).toBe(201) + expect(response.headers.get('x-service')).toBe('ok') + expect(await response.text()).toBe('service-ok') + expect(calls[0]?.method).toBe('API.service.fetch') + expect((calls[0]?.params[0] as { method?: string }).method).toBe('POST') + }) +}) + +describe('createEnvProxy strict unknown bindings', () => { + test('returns undefined for unknown platform env names instead of generic bridge proxies', () => { + const client = { + async call() { + throw new Error('unexpected bridge call') + } + } + + const env = createEnvProxy({ + client: client as never, + hints: { API: 'service' }, + strict: true + } as never) + + expect(env.MISSING_VALUE).toBeUndefined() + expect(String(env.MISSING_VALUE)).toBe('undefined') + expect('MISSING_VALUE' in env).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/bridge/serialization.test.ts b/packages/devflare/tests/unit/bridge/serialization.test.ts new file mode 100644 index 0000000..e00e4fa --- /dev/null +++ b/packages/devflare/tests/unit/bridge/serialization.test.ts @@ -0,0 +1,203 @@ +// ============================================================================= +// Bridge Serialization โ€” Special Value Round-Trip Tests +// ============================================================================= + +import { describe, test, expect } from 'bun:test' +import { + serializeValue, + deserializeValue, + serializeRequest, + deserializeRequest, + serializeResponse, + deserializeResponse, + serializeDOId, + deserializeDOId, + DO_ID_TYPE +} from '../../../src/bridge/v2/value-serialization' + +async function roundTrip(value: T): Promise { + const { value: encoded } = await serializeValue(value) + // Simulate a JSON transport round-trip + const transported = JSON.parse(JSON.stringify(encoded)) + return deserializeValue(transported) +} + +describe('serializeValue / deserializeValue โ€” special objects', () => { + test('round-trips Date', async () => { + const original = new Date('2025-01-02T03:04:05.678Z') + const result = await roundTrip(original) + expect(result).toBeInstanceOf(Date) + expect((result as Date).toISOString()).toBe(original.toISOString()) + }) + + test('round-trips URL via .href', async () => { + const original = new URL('https://example.com/path?q=1#frag') + const result = await roundTrip(original) + expect(result).toBeInstanceOf(URL) + expect((result as URL).href).toBe(original.href) + }) + + test('round-trips Error preserving name/message/stack', async () => { + const original = new TypeError('boom') + const result = await roundTrip(original) + expect(result).toBeInstanceOf(Error) + expect((result as Error).name).toBe('TypeError') + expect((result as Error).message).toBe('boom') + expect(typeof (result as Error).stack).toBe('string') + }) + + test('round-trips Map with nested special values', async () => { + const original = new Map([ + ['home', new URL('https://example.com/')], + ['docs', new URL('https://example.com/docs')] + ]) + const result = await roundTrip(original) + expect(result).toBeInstanceOf(Map) + const resMap = result as Map + expect(resMap.size).toBe(2) + expect(resMap.get('home')).toBeInstanceOf(URL) + expect(resMap.get('home')?.href).toBe('https://example.com/') + expect(resMap.get('docs')?.href).toBe('https://example.com/docs') + }) + + test('round-trips Set with nested special values', async () => { + const d1 = new Date('2025-05-05T00:00:00.000Z') + const d2 = new Date('2026-06-06T00:00:00.000Z') + const original = new Set([d1, d2]) + const result = await roundTrip(original) + expect(result).toBeInstanceOf(Set) + const resSet = result as Set + expect(resSet.size).toBe(2) + const isoValues = Array.from(resSet).map((d) => d.toISOString()).sort() + expect(isoValues).toEqual([d1.toISOString(), d2.toISOString()]) + }) + + test('round-trips deeply nested mixed structure', async () => { + const original = { + when: new Date('2025-07-08T09:10:11.000Z'), + tags: new Set(['alpha', 'beta']), + meta: new Map([['root', new URL('https://devflare.dev/')]]), + cause: new Error('kapow') + } + + const result = (await roundTrip(original)) as typeof original + + expect(result.when).toBeInstanceOf(Date) + expect(result.when.toISOString()).toBe(original.when.toISOString()) + + expect(result.tags).toBeInstanceOf(Set) + expect(Array.from(result.tags).sort()).toEqual(['alpha', 'beta']) + + expect(result.meta).toBeInstanceOf(Map) + expect(result.meta.get('root')).toBeInstanceOf(URL) + expect(result.meta.get('root')?.href).toBe('https://devflare.dev/') + + expect(result.cause).toBeInstanceOf(Error) + expect(result.cause.message).toBe('kapow') + }) +}) + +describe('serializeValue / deserializeValue โ€” primitives & plain containers', () => { + test('round-trips primitives', async () => { + expect(await roundTrip(42)).toBe(42) + expect(await roundTrip('hello')).toBe('hello') + expect(await roundTrip(true)).toBe(true) + expect(await roundTrip(false)).toBe(false) + expect(await roundTrip(null)).toBe(null) + }) + + test('preserves undefined at top level', async () => { + const { value: encoded } = await serializeValue(undefined) + expect(encoded).toBeUndefined() + expect(deserializeValue(encoded)).toBeUndefined() + }) + + test('round-trips plain arrays', async () => { + const original = [1, 'two', true, null, [3, 4]] + const result = await roundTrip(original) + expect(result).toEqual(original) + }) + + test('round-trips plain objects', async () => { + const original = { a: 1, b: 'two', c: { d: [5, 6] } } + const result = await roundTrip(original) + expect(result).toEqual(original) + }) +}) + +describe('serializeRequest / serializeResponse \u2014 body transport', () => { + test('inline Request body round-trips through bytes branch', async () => { + const original = new Request('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'hello world' + }) + + const { serialized } = await serializeRequest(original) + expect(serialized.body?.type).toBe('bytes') + + const transported = JSON.parse(JSON.stringify(serialized)) + const restored = deserializeRequest(transported) + expect(await restored.text()).toBe('hello world') + }) + + test('inline Response body round-trips through bytes branch', async () => { + const original = new Response('payload', { status: 201, headers: { 'X-Test': '1' } }) + + const { serialized } = await serializeResponse(original) + expect(serialized.body?.type).toBe('bytes') + + const transported = JSON.parse(JSON.stringify(serialized)) + const restored = deserializeResponse(transported) + expect(restored.status).toBe(201) + expect(restored.headers.get('X-Test')).toBe('1') + expect(await restored.text()).toBe('payload') + }) + + test('serializeRequest throws for bodies above the http threshold', async () => { + const big = new Uint8Array(32) + const original = new Request('https://example.com/upload', { method: 'POST', body: big }) + + await expect(serializeRequest(original, { httpThreshold: 16 })).rejects.toThrow( + /http body transfer not implemented/ + ) + }) + + test('serializeResponse throws for bodies above the http threshold', async () => { + const original = new Response(new Uint8Array(32)) + + await expect(serializeResponse(original, { httpThreshold: 16 })).rejects.toThrow( + /http body transfer not implemented/ + ) + }) +}) + +describe('serializeDOId / deserializeDOId \u2014 canonical wire shape', () => { + test('emits the canonical { __type: DOId, hex } wire shape', () => { + const fakeId = { toString: () => 'deadbeef' } as unknown as DurableObjectId + const serialized = serializeDOId(fakeId) + expect(serialized).toEqual({ __type: DO_ID_TYPE, hex: 'deadbeef' }) + expect(serialized.__type).toBe('DOId') + }) + + test('deserializeDOId round-trips through a DurableObjectNamespace.idFromString', () => { + const fakeId = { toString: () => 'cafef00d' } as unknown as DurableObjectId + const received: string[] = [] + const ns = { + idFromString: (hex: string) => { + received.push(hex) + return fakeId + } + } as unknown as DurableObjectNamespace + + const serialized = serializeDOId(fakeId) + const restored = deserializeDOId(serialized, ns) + expect(restored).toBe(fakeId) + expect(received).toEqual(['cafef00d']) + }) + + test('deserializeDOId rejects unknown shapes', () => { + const ns = { idFromString: () => { throw new Error('should not be called') } } as unknown as DurableObjectNamespace + expect(() => deserializeDOId({ type: 'do-id', hexId: 'x' } as never, ns)).toThrow(/Invalid DOId format/) + }) +}) diff --git a/packages/devflare/tests/unit/bridge/server-rpc.test.ts b/packages/devflare/tests/unit/bridge/server-rpc.test.ts new file mode 100644 index 0000000..abda04d --- /dev/null +++ b/packages/devflare/tests/unit/bridge/server-rpc.test.ts @@ -0,0 +1,210 @@ +// ============================================================================= +// Bridge Gateway โ€” executeRpcMethod dispatch tests +// ============================================================================= + +import { describe, test, expect } from 'bun:test' +import { executeRpcMethod } from '../../../src/bridge/server' +import type { GatewayEnv } from '../../../src/bridge/server' +import { serializeRequest } from '../../../src/bridge/v2/value-serialization' + +const noopCtx = { + waitUntil: () => { }, + passThroughOnException: () => { } +} as unknown as ExecutionContext + +describe('executeRpcMethod โ€” namespaced dispatch', () => { + test('kv.get dispatches to KVNamespace.get', async () => { + const calls: unknown[][] = [] + const kv = { + get: (...args: unknown[]) => { + calls.push(args) + return 'kv-value' + }, + put: () => { }, + list: () => { }, + delete: () => { }, + getWithMetadata: () => { } + } + const env = { MY_KV: kv } as unknown as GatewayEnv + + const result = await executeRpcMethod('MY_KV.kv.get', ['some-key', { type: 'text' }], env, noopCtx) + + expect(result).toBe('kv-value') + expect(calls).toEqual([['some-key', { type: 'text' }]]) + }) + + test('do.get returns DOStub reference', async () => { + const stub = { fetch: () => new Response('ok') } + const idObj = { __id: 'abc' } + const doNs = { + idFromName: () => idObj, + idFromString: () => idObj, + newUniqueId: () => idObj, + get: () => stub + } + const env = { MY_DO: doNs } as unknown as GatewayEnv + + const serializedId = { __type: 'DOId', hex: 'abc' } + const result = await executeRpcMethod('MY_DO.do.get', [serializedId], env, noopCtx) + + expect(result).toEqual({ __type: 'DOStub', binding: 'MY_DO', id: serializedId }) + }) + + test('queue.send dispatches to Queue.send', async () => { + const sent: unknown[] = [] + const queue = { + send: (msg: unknown) => { sent.push(msg) }, + sendBatch: () => { } + } + const env = { MY_Q: queue } as unknown as GatewayEnv + await executeRpcMethod('MY_Q.queue.send', [{ hello: 'world' }], env, noopCtx) + expect(sent).toEqual([{ hello: 'world' }]) + }) + + test('ai.run dispatches to AI.run', async () => { + const ai = { run: (m: string, i: unknown) => ({ model: m, inputs: i }) } + const env = { AI: ai } as unknown as GatewayEnv + const result = await executeRpcMethod('AI.ai.run', ['@cf/x', { prompt: 'hi' }], env, noopCtx) + expect(result).toEqual({ model: '@cf/x', inputs: { prompt: 'hi' } }) + }) + + test('ai.run throws when binding lacks run()', async () => { + const env = { AI: {} } as unknown as GatewayEnv + await expect( + executeRpcMethod('AI.ai.run', ['@cf/x', {}], env, noopCtx) + ).rejects.toThrow(/does not support run/) + }) + + test('service.fetch dispatches to Cloudflare service binding fetch', async () => { + const service = { + async fetch(request: Request) { + return new Response(`service:${request.method}:${await request.text()}`, { + status: 202, + headers: { 'x-service': 'ok' } + }) + } + } + const env = { API: service } as unknown as GatewayEnv + const { serialized } = await serializeRequest(new Request('https://api.local/action', { + method: 'POST', + body: 'payload' + })) + + const result = await executeRpcMethod('API.service.fetch', [serialized], env, noopCtx) as Response + + expect(result.status).toBe(202) + expect(result.headers.get('x-service')).toBe('ok') + expect(await result.text()).toBe('service:POST:payload') + }) +}) + +describe('executeRpcMethod โ€” B3-final: bare verbs and legacy sub-prefixes throw', () => { + const env = { + K: { + get: () => 'v', + put: () => { }, + list: () => { }, + delete: () => { }, + getWithMetadata: () => { } + }, + D: { + idFromName: () => ({ __id: 'a' }), + idFromString: () => ({ __id: 'a' }), + newUniqueId: () => ({ __id: 'a' }), + get: () => ({ fetch: () => new Response('ok') }) + }, + DB: { + prepare: () => ({ first: () => ({ id: 1 }), bind: () => ({}) }), + exec: () => { }, + batch: () => { }, + dump: () => { } + }, + X: { foo: () => { } } + } as unknown as GatewayEnv + + test('bare verb on KV-shaped binding throws (no fallback translation)', async () => { + await expect( + executeRpcMethod('K.get', ['k1'], env, noopCtx) + ).rejects.toThrow(/Unsupported bridge operation 'get'/) + }) + + test('bare verb on DO-shaped binding throws', async () => { + const serializedId = { __type: 'DOId', hex: 'abc' } + await expect( + executeRpcMethod('D.get', [serializedId], env, noopCtx) + ).rejects.toThrow(/Unsupported bridge operation 'get'/) + }) + + test('bare verb on unknown binding kind throws', async () => { + await expect( + executeRpcMethod('X.get', ['k'], env, noopCtx) + ).rejects.toThrow(/Unsupported bridge operation 'get'/) + }) + + test('bare fetch on a service-shaped binding explains the SvelteKit service-binding path', async () => { + await expect( + executeRpcMethod('X.fetch', [new Request('https://api.local/')], env, noopCtx) + ).rejects.toThrow(/Expected Cloudflare API: env\.X\.fetch\(request\)/) + }) + + test('legacy stmt.first prefix throws (use d1.stmt.first)', async () => { + await expect( + executeRpcMethod('DB.stmt.first', ['SELECT 1'], env, noopCtx) + ).rejects.toThrow(/Unsupported bridge operation 'stmt\.first'/) + }) + + test('legacy stub.fetch prefix throws (use do.fetch)', async () => { + await expect( + executeRpcMethod('D.stub.fetch', [], env, noopCtx) + ).rejects.toThrow(/Unsupported bridge operation 'stub\.fetch'/) + }) +}) + +describe('executeRpcMethod โ€” B5-frame: binding errors round-trip with typed cause', () => { + test('KV.get throw surfaces back through executeRpcMethod', async () => { + const kv = { + get: () => { throw new Error('kv blew up') }, + put: () => { }, + list: () => { }, + delete: () => { }, + getWithMetadata: () => { } + } + const env = { K: kv } as unknown as GatewayEnv + await expect( + executeRpcMethod('K.kv.get', ['k1'], env, noopCtx) + ).rejects.toThrow(/kv blew up/) + }) + + test('R2.get throw surfaces back through executeRpcMethod', async () => { + const r2 = { + head: () => null, + get: () => { throw new Error('r2 blew up') }, + put: () => { }, + delete: () => { }, + list: () => { }, + createMultipartUpload: () => { } + } + const env = { B: r2 } as unknown as GatewayEnv + await expect( + executeRpcMethod('B.r2.get', ['some/key'], env, noopCtx) + ).rejects.toThrow(/r2 blew up/) + }) + + test('D1 prepare-then-first throw surfaces back through executeRpcMethod', async () => { + const stmt = { + bind: () => stmt, + first: () => { throw new Error('d1 blew up') } + } + const d1 = { + prepare: () => stmt, + exec: () => { }, + batch: () => { }, + dump: () => { } + } + const env = { DB: d1 } as unknown as GatewayEnv + await expect( + executeRpcMethod('DB.d1.stmt.first', ['SELECT 1'], env, noopCtx) + ).rejects.toThrow(/d1 blew up/) + }) +}) + diff --git a/packages/devflare/tests/unit/bridge/v2/body-streams.test.ts b/packages/devflare/tests/unit/bridge/v2/body-streams.test.ts new file mode 100644 index 0000000..c540f01 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/v2/body-streams.test.ts @@ -0,0 +1,251 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Body Stream Reader/Writer Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + TRANSPORT_V2_BINARY_HEADER_SIZE, + TransportV2BinaryFlags, + TransportV2BinaryKind, + TransportV2BodyReaderRegistry, + decodeTransportV2BinaryFrame, + parseTransportV2ControlMsg, + writeTransportV2Body +} from '../../../../src/bridge/v2' + +function readableStreamFromChunks(chunks: Uint8Array[]): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index >= chunks.length) { + controller.close() + return + } + controller.enqueue(chunks[index]) + index += 1 + } + }) +} + +interface CapturedFrames { + text: string[] + binary: Uint8Array[] +} + +function captureIo() { + const captured: CapturedFrames = { text: [], binary: [] } + return { + captured, + io: { + sendText: (m: string) => captured.text.push(m), + sendBinary: (f: Uint8Array) => captured.binary.push(f) + } + } +} + +describe('writeTransportV2Body', () => { + test('emits body.open + chunked BodyChunk frames + FIN + body.end', async () => { + const { captured, io } = captureIo() + const payload = new Uint8Array(700) + for (let i = 0; i < payload.length; i++) payload[i] = i % 256 + const source = readableStreamFromChunks([payload]) + + await writeTransportV2Body(source, { + bid: 1, + kind: 'request', + rpcId: 'rpc_x', + io, + writerOptions: { chunkSize: 256, contentType: 'application/octet-stream', contentLength: 700 } + }) + + expect(captured.text).toHaveLength(2) + const open = parseTransportV2ControlMsg(captured.text[0]!) + const end = parseTransportV2ControlMsg(captured.text[1]!) + expect(open).toEqual({ + t: 'body.open', + bid: 1, + kind: 'request', + rpcId: 'rpc_x', + contentType: 'application/octet-stream', + contentLength: 700 + }) + expect(end).toEqual({ t: 'body.end', bid: 1, kind: 'request' }) + + // 700 bytes / 256 chunk size โ†’ 3 data frames + 1 trailing FIN frame. + expect(captured.binary).toHaveLength(4) + const decoded = captured.binary.map((b) => decodeTransportV2BinaryFrame(b)) + expect(decoded[0]!.payload.byteLength).toBe(256) + expect(decoded[1]!.payload.byteLength).toBe(256) + expect(decoded[2]!.payload.byteLength).toBe(700 - 512) + expect(decoded[3]!.payload.byteLength).toBe(0) + expect(decoded[3]!.flags).toBe(TransportV2BinaryFlags.FIN) + expect(decoded.map((f) => f.kind)).toEqual([ + TransportV2BinaryKind.BodyChunk, + TransportV2BinaryKind.BodyChunk, + TransportV2BinaryKind.BodyChunk, + TransportV2BinaryKind.BodyChunk + ]) + expect(decoded.map((f) => f.seq)).toEqual([0, 1, 2, 3]) + }) + + test('emits a single FIN frame + body.end for an empty source', async () => { + const { captured, io } = captureIo() + const source = readableStreamFromChunks([]) + + await writeTransportV2Body(source, { bid: 9, kind: 'response', rpcId: 'r', io }) + + expect(captured.text).toHaveLength(2) + expect(captured.binary).toHaveLength(1) + const frame = decodeTransportV2BinaryFrame(captured.binary[0]!) + expect(frame.flags).toBe(TransportV2BinaryFlags.FIN) + expect(frame.payload.byteLength).toBe(0) + }) + + test('rejects non-positive chunk size', async () => { + const { io } = captureIo() + await expect( + writeTransportV2Body(readableStreamFromChunks([]), { + bid: 1, + kind: 'request', + rpcId: 'r', + io, + writerOptions: { chunkSize: 0 } + }) + ).rejects.toThrow(/chunk size must be > 0/) + }) + + test('emits body.abort + ABORT-flagged frame when the source errors', async () => { + const { captured, io } = captureIo() + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('source exploded')) + } + }) + + await expect( + writeTransportV2Body(source, { bid: 3, kind: 'request', rpcId: 'r', io }) + ).rejects.toThrow(/source exploded/) + + // At minimum: body.open + body.abort on the text channel; one ABORT-flagged frame on the binary channel. + expect(captured.text.length).toBeGreaterThanOrEqual(2) + const abortMsg = parseTransportV2ControlMsg(captured.text[captured.text.length - 1]!) + expect(abortMsg.t).toBe('body.abort') + + const lastBinary = captured.binary[captured.binary.length - 1]! + const decoded = decodeTransportV2BinaryFrame(lastBinary) + expect(decoded.flags & TransportV2BinaryFlags.ABORT).toBe(TransportV2BinaryFlags.ABORT) + }) +}) + +describe('TransportV2BodyReaderRegistry', () => { + test('open/getOrOpen/end deliver chunks then close the stream', async () => { + const registry = new TransportV2BodyReaderRegistry() + const stream = registry.getOrOpen(7) + + registry.pushChunk({ + kind: TransportV2BinaryKind.BodyChunk, + id: 7, + seq: 0, + flags: 0, + payload: new Uint8Array([1, 2, 3]) + }) + registry.pushChunk({ + kind: TransportV2BinaryKind.BodyChunk, + id: 7, + seq: 1, + flags: TransportV2BinaryFlags.FIN, + payload: new Uint8Array(0) + }) + + const reader = stream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect([...(first.value ?? [])]).toEqual([1, 2, 3]) + const second = await reader.read() + expect(second.done).toBe(true) + // After FIN the bid is no longer tracked. + expect(registry.size).toBe(0) + }) + + test('getOrOpen is idempotent', () => { + const registry = new TransportV2BodyReaderRegistry() + const a = registry.getOrOpen(11) + const b = registry.getOrOpen(11) + expect(a).toBe(b) + }) + + test('open() throws if bid is already registered', () => { + const registry = new TransportV2BodyReaderRegistry() + registry.open(99) + expect(() => registry.open(99)).toThrow(/already registered for bid 99/) + }) + + test('abort() errors the stream', async () => { + const registry = new TransportV2BodyReaderRegistry() + const stream = registry.open(2) + registry.abort(2, 'peer cancelled') + const reader = stream.getReader() + await expect(reader.read()).rejects.toThrow(/peer cancelled/) + }) + + test('ABORT flag in a chunk frame errors the stream', async () => { + const registry = new TransportV2BodyReaderRegistry() + const stream = registry.open(4) + registry.pushChunk({ + kind: TransportV2BinaryKind.BodyChunk, + id: 4, + seq: 0, + flags: TransportV2BinaryFlags.ABORT, + payload: new Uint8Array(0) + }) + const reader = stream.getReader() + await expect(reader.read()).rejects.toThrow(/aborted by writer/) + }) + + test('rejects non-BodyChunk frames in pushChunk', () => { + const registry = new TransportV2BodyReaderRegistry() + registry.open(5) + expect(() => + registry.pushChunk({ + kind: TransportV2BinaryKind.WsData, + id: 5, + seq: 0, + flags: 0, + payload: new Uint8Array(0) + }) + ).toThrow(/non-BodyChunk frame/) + }) + + test('chunks copied so pushed buffers can be reused', async () => { + const registry = new TransportV2BodyReaderRegistry() + const stream = registry.open(13) + const sourceBuffer = new Uint8Array([10, 20, 30]) + registry.pushChunk({ + kind: TransportV2BinaryKind.BodyChunk, + id: 13, + seq: 0, + flags: TransportV2BinaryFlags.FIN, + payload: sourceBuffer + }) + // Mutate the source buffer after push. + sourceBuffer.fill(0) + const reader = stream.getReader() + const result = await reader.read() + expect([...(result.value ?? [])]).toEqual([10, 20, 30]) + }) +}) + +describe('TRANSPORT_V2_BINARY_HEADER_SIZE invariant', () => { + test('writer emits frames whose header length matches the constant', async () => { + const { captured, io } = captureIo() + await writeTransportV2Body(readableStreamFromChunks([new Uint8Array([7])]), { + bid: 1, + kind: 'request', + rpcId: 'r', + io + }) + for (const frame of captured.binary) { + expect(frame.byteLength).toBeGreaterThanOrEqual(TRANSPORT_V2_BINARY_HEADER_SIZE) + } + }) +}) diff --git a/packages/devflare/tests/unit/bridge/v2/codec.test.ts b/packages/devflare/tests/unit/bridge/v2/codec.test.ts new file mode 100644 index 0000000..41f9850 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/v2/codec.test.ts @@ -0,0 +1,254 @@ +๏ปฟ// ============================================================================= +// Bridge Transport v2 รขโ‚ฌโ€ End-to-End Codec + Streaming Serialization Tests +// ============================================================================= +// +// These tests use the in-memory `createTransportV2Pair()` to wire two +// `TransportV2Codec` instances together and exercise the full v2 stack: +// handshake, RPC, and streaming `Request`/`Response` body transfer through +// `serializeRequestV2` / `deserializeRequestV2`. +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + TransportV2Codec, + createTransportV2Pair, + deserializeRequestV2, + deserializeResponseV2, + serializeRequestV2, + serializeResponseV2 +} from '../../../../src/bridge/v2' + +function pair(opts?: { + clientCaps?: string[] + serverCaps?: string[] + onServerCall?: (call: import('../../../../src/bridge/v2').TransportV2RpcCall, server: TransportV2Codec) => void +}) { + const { a, b } = createTransportV2Pair() + const client = new TransportV2Codec(a, { capabilities: opts?.clientCaps ?? [] }) + const server = new TransportV2Codec(b, { + capabilities: opts?.serverCaps ?? [], + onRpcCall: (call) => opts?.onServerCall?.(call, server) + }) + return { client, server } +} + +describe('TransportV2Codec รขโ‚ฌโ€ handshake', () => { + test('client.sendHello() resolves both sides with the negotiated capability intersection', async () => { + const { client, server } = pair({ + clientCaps: ['streaming-bodies', 'codegen-gateway', 'experimental'], + serverCaps: ['streaming-bodies', 'codegen-gateway'] + }) + client.sendHello() + const [clientResult, serverResult] = await Promise.all([client.handshake, server.handshake]) + expect(clientResult.protocolVersion).toBe(2) + expect(serverResult.protocolVersion).toBe(2) + expect(clientResult.capabilities).toEqual(['codegen-gateway', 'streaming-bodies']) + expect(serverResult.capabilities).toEqual(['codegen-gateway', 'streaming-bodies']) + }) + + test('handshake rejects when the underlying transport closes before completion', async () => { + const { client, server } = pair() + // Pre-attach a catch on the server side so its rejection (when the + // peer's close cascades through) does not surface as an unhandled + // promise rejection in the test runner. + server.handshake.catch(() => { }) + client.close(1000, 'before hello') + await expect(client.handshake).rejects.toThrow(/v2 transport closed/) + }) +}) + +describe('TransportV2Codec รขโ‚ฌโ€ RPC', () => { + test('client.call resolves with the server\'s rpc.ok result', async () => { + const { client, server } = pair({ + onServerCall: (call, srv) => { + if (call.method === 'echo') srv.respondOk(call.id, call.params[0]) + } + }) + client.sendHello() + await Promise.all([client.handshake, server.handshake]) + const result = await client.call('echo', ['hello world']) + expect(result).toBe('hello world') + }) + + test('client.call rejects with the server\'s rpc.err message', async () => { + const { client, server } = pair({ + onServerCall: (call, srv) => { + srv.respondErr(call.id, { code: 'EBOOM', message: 'server exploded', details: { trace: 'x' } }) + } + }) + client.sendHello() + await Promise.all([client.handshake, server.handshake]) + await expect(client.call('whatever')).rejects.toMatchObject({ + message: 'server exploded' + }) + }) + + test('all pending RPC calls reject when the codec closes', async () => { + const { client, server } = pair({ + // Server intentionally never replies. + onServerCall: () => { } + }) + client.sendHello() + await Promise.all([client.handshake, server.handshake]) + const pending = client.call('never-resolves') + client.close() + await expect(pending).rejects.toThrow(/v2 transport closed/) + }) +}) + +describe('serializeRequestV2 / deserializeRequestV2 รขโ‚ฌโ€ streaming bodies', () => { + test('round-trips a streaming Request body through v2 without buffering', async () => { + const { client, server } = pair() + client.sendHello() + await Promise.all([client.handshake, server.handshake]) + + const sourceText = 'a'.repeat(1500) + const sourceRequest = new Request('https://example.com/upload', { + method: 'POST', + headers: { 'content-type': 'text/plain', 'content-length': String(sourceText.length) }, + body: sourceText + }) + + const serverCall = new Promise((resolve, reject) => { + server.setRpcCallHandler((call) => { + if (call.method !== 'upload') return + try { + const serialized = call.params[0] as import('../../../../src/bridge/v2').TransportV2SerializedRequest + const reconstructed = deserializeRequestV2(serialized, server) + reconstructed.text().then((text) => { + server.respondOk(call.id, { length: text.length }) + resolve(text) + }).catch(reject) + } catch (error) { + reject(error as Error) + } + }) + }) + + const { serialized, bodyStreamPromise } = serializeRequestV2(sourceRequest, client, 'rpc_test_1') + expect(serialized.body?.type).toBe('stream') + + const replyPromise = client.call('upload', [serialized]) + await bodyStreamPromise + const [reply, serverText] = await Promise.all([replyPromise, serverCall]) + + expect(reply).toEqual({ length: sourceText.length }) + expect(serverText).toBe(sourceText) + }) + + test('serializeRequestV2 emits no body ref for an empty body', () => { + const { client } = pair() + const request = new Request('https://example.com/', { method: 'GET' }) + const { serialized } = serializeRequestV2(request, client, 'rpc_x') + expect(serialized.body).toBeNull() + }) + + test('round-trips a streaming Response body through v2 without buffering', async () => { + const { client, server } = pair() + client.sendHello() + await Promise.all([client.handshake, server.handshake]) + + const responseText = 'response-bytes-' + 'b'.repeat(500) + + server.setRpcCallHandler((call) => { + if (call.method !== 'download') return + const response = new Response(responseText, { + status: 200, + headers: { 'content-type': 'text/plain' } + }) + const { serialized, bodyStreamPromise } = serializeResponseV2(response, server, call.id) + server.respondOk(call.id, { response: serialized }) + bodyStreamPromise.catch(() => { }) + }) + + const reply = await client.call('download', []) + const serializedResponse = (reply as { response: import('../../../../src/bridge/v2').TransportV2SerializedResponse }).response + const reconstructed = deserializeResponseV2(serializedResponse, client) + const text = await reconstructed.text() + expect(reconstructed.status).toBe(200) + expect(text).toBe(responseText) + }) +}) + +describe('TransportV2Codec รขโ‚ฌโ€ frame routing isolation', () => { + test('non-v2 control messages are forwarded to onUnknownControl', async () => { + const { a, b } = createTransportV2Pair() + const seen: string[] = [] + const left = new TransportV2Codec(a, { onUnknownControl: (m) => seen.push(m) }) + const right = new TransportV2Codec(b) + // No handshake in this test; pre-catch to suppress unhandled rejection on close. + left.handshake.catch(() => { }) + right.handshake.catch(() => { }) + // Manually post a v1 message kind: + right.sendText('{"t":"event","topic":"v1-topic","data":42}') + // Wait one microtask cycle for delivery. + await Promise.resolve() + await Promise.resolve() + expect(seen).toEqual(['{"t":"event","topic":"v1-topic","data":42}']) + left.close() + right.close() + }) + + test('non-BodyChunk binary frames are forwarded to onUnknownBinary', async () => { + const { a, b } = createTransportV2Pair() + const seen: import('../../../../src/bridge/v2').TransportV2DecodedBinaryFrame[] = [] + const left = new TransportV2Codec(a, { onUnknownBinary: (f) => seen.push(f) }) + const right = new TransportV2Codec(b) + left.handshake.catch(() => { }) + right.handshake.catch(() => { }) + const { encodeTransportV2BinaryFrame, TransportV2BinaryKind } = await import('../../../../src/bridge/v2') + right.sendBinary( + encodeTransportV2BinaryFrame(TransportV2BinaryKind.WsData, 1, 0, 0, new Uint8Array([1, 2, 3])) + ) + await Promise.resolve() + await Promise.resolve() + expect(seen).toHaveLength(1) + expect(seen[0]!.kind).toBe(TransportV2BinaryKind.WsData) + expect([...seen[0]!.payload]).toEqual([1, 2, 3]) + left.close() + right.close() + }) +}) + +describe('TransportV2Codec โ€” B5-frame: out-of-band wire error', () => { + test('sendWireError on one side fires onWireError on the other', async () => { + const { a, b } = createTransportV2Pair() + const seen: import('../../../../src/bridge/v2').TransportV2WireError[] = [] + const left = new TransportV2Codec(a, { onWireError: (e) => seen.push(e) }) + const right = new TransportV2Codec(b) + left.handshake.catch(() => { }) + right.handshake.catch(() => { }) + right.sendWireError({ + scope: 'stream', + error: { code: 'EBADCHUNK', message: 'malformed body chunk', details: { sid: 7 } }, + refId: 7 + }) + await Promise.resolve() + await Promise.resolve() + expect(seen).toHaveLength(1) + expect(seen[0]!.t).toBe('error') + expect(seen[0]!.scope).toBe('stream') + expect(seen[0]!.error.code).toBe('EBADCHUNK') + expect(seen[0]!.error.message).toBe('malformed body chunk') + expect(seen[0]!.refId).toBe(7) + left.close() + right.close() + }) + + test('malformed error frames fall through to onUnknownControl', async () => { + const { a, b } = createTransportV2Pair() + const unknown: string[] = [] + const left = new TransportV2Codec(a, { onUnknownControl: (m) => unknown.push(m) }) + const right = new TransportV2Codec(b) + left.handshake.catch(() => { }) + right.handshake.catch(() => { }) + // scope missing โ€” must not be parsed as a wire error. + right.sendText('{"t":"error","error":{"code":"X","message":"y"}}') + await Promise.resolve() + await Promise.resolve() + expect(unknown).toHaveLength(1) + left.close() + right.close() + }) +}) + diff --git a/packages/devflare/tests/unit/bridge/v2/frames.test.ts b/packages/devflare/tests/unit/bridge/v2/frames.test.ts new file mode 100644 index 0000000..5e50d3e --- /dev/null +++ b/packages/devflare/tests/unit/bridge/v2/frames.test.ts @@ -0,0 +1,220 @@ +// ============================================================================= +// Bridge Transport v2 โ€” Frame Vocabulary Tests +// ============================================================================= +// +// Unit tests for the foundation frame encoders/decoders, handshake parsing, +// and capability negotiation. Pure logic only; nothing here touches the v1 +// transport. +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + TRANSPORT_V2_BINARY_HEADER_SIZE, + TRANSPORT_V2_PROTOCOL_VERSION, + TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE, + TransportV2BinaryFlags, + TransportV2BinaryKind, + decodeTransportV2BinaryFrame, + encodeTransportV2BinaryFrame, + negotiateTransportV2Capabilities, + parseTransportV2ControlMsg, + stringifyTransportV2ControlMsg, + transportV2IsAbort, + transportV2IsFin, + transportV2IsText +} from '../../../../src/bridge/v2' +import type { TransportV2ControlMsg } from '../../../../src/bridge/v2' + +describe('transport v2 โ€” protocol constants', () => { + test('protocol version is pinned at 2', () => { + expect(TRANSPORT_V2_PROTOCOL_VERSION).toBe(2) + }) + + test('binary header size is 10 bytes (matches v1 byte layout)', () => { + expect(TRANSPORT_V2_BINARY_HEADER_SIZE).toBe(10) + }) + + test('unsupported-version close code is in the reserved private range', () => { + expect(TRANSPORT_V2_UNSUPPORTED_VERSION_CLOSE_CODE).toBe(4001) + }) + + test('binary kinds 1 and 2 are stable with v1; kind 3 is new for body chunks', () => { + expect(TransportV2BinaryKind.StreamChunk).toBe(1) + expect(TransportV2BinaryKind.WsData).toBe(2) + expect(TransportV2BinaryKind.BodyChunk).toBe(3) + }) + + test('binary flags use disjoint bits', () => { + expect(TransportV2BinaryFlags.FIN).toBe(0b0001) + expect(TransportV2BinaryFlags.TEXT).toBe(0b0010) + expect(TransportV2BinaryFlags.ABORT).toBe(0b0100) + }) +}) + +describe('transport v2 โ€” binary frame encoder/decoder', () => { + test('round-trips a body chunk frame with FIN flag set', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]) + const encoded = encodeTransportV2BinaryFrame( + TransportV2BinaryKind.BodyChunk, + 42, + 7, + TransportV2BinaryFlags.FIN, + payload + ) + + expect(encoded.byteLength).toBe(TRANSPORT_V2_BINARY_HEADER_SIZE + payload.byteLength) + + const decoded = decodeTransportV2BinaryFrame(encoded) + + expect(decoded.kind).toBe(TransportV2BinaryKind.BodyChunk) + expect(decoded.id).toBe(42) + expect(decoded.seq).toBe(7) + expect(decoded.flags).toBe(TransportV2BinaryFlags.FIN) + expect([...decoded.payload]).toEqual([1, 2, 3, 4, 5]) + expect(transportV2IsFin(decoded.flags)).toBe(true) + expect(transportV2IsText(decoded.flags)).toBe(false) + expect(transportV2IsAbort(decoded.flags)).toBe(false) + }) + + test('round-trips a ws data frame with TEXT and ABORT flags combined', () => { + const payload = new TextEncoder().encode('aborted text frame') + const flags = TransportV2BinaryFlags.TEXT | TransportV2BinaryFlags.ABORT + const encoded = encodeTransportV2BinaryFrame( + TransportV2BinaryKind.WsData, + 1, + 0, + flags, + payload + ) + const decoded = decodeTransportV2BinaryFrame(encoded) + + expect(decoded.kind).toBe(TransportV2BinaryKind.WsData) + expect(transportV2IsText(decoded.flags)).toBe(true) + expect(transportV2IsAbort(decoded.flags)).toBe(true) + expect(transportV2IsFin(decoded.flags)).toBe(false) + expect(new TextDecoder().decode(decoded.payload)).toBe('aborted text frame') + }) + + test('encodes ids in little-endian byte order', () => { + const encoded = encodeTransportV2BinaryFrame( + TransportV2BinaryKind.BodyChunk, + 0x01020304, + 0x05060708, + 0, + new Uint8Array(0) + ) + + // kind=3 at byte 0; id (LE) at bytes 1..4; seq (LE) at bytes 5..8; flags at byte 9 + expect(encoded[0]).toBe(3) + expect([...encoded.slice(1, 5)]).toEqual([0x04, 0x03, 0x02, 0x01]) + expect([...encoded.slice(5, 9)]).toEqual([0x08, 0x07, 0x06, 0x05]) + expect(encoded[9]).toBe(0) + }) + + test('rejects out-of-range ids, seq, and flags', () => { + const empty = new Uint8Array(0) + expect(() => + encodeTransportV2BinaryFrame(TransportV2BinaryKind.BodyChunk, -1, 0, 0, empty) + ).toThrow(RangeError) + expect(() => + encodeTransportV2BinaryFrame(TransportV2BinaryKind.BodyChunk, 0, 0xffffffff + 1, 0, empty) + ).toThrow(RangeError) + expect(() => + encodeTransportV2BinaryFrame(TransportV2BinaryKind.BodyChunk, 0, 0, 0x100, empty) + ).toThrow(RangeError) + }) + + test('rejects under-length frames during decode', () => { + expect(() => decodeTransportV2BinaryFrame(new Uint8Array(5))).toThrow(/too short/) + }) + + test('rejects unknown binary kinds during decode', () => { + const buf = new Uint8Array(TRANSPORT_V2_BINARY_HEADER_SIZE) + buf[0] = 99 + expect(() => decodeTransportV2BinaryFrame(buf)).toThrow(/unknown kind 99/) + }) +}) + +describe('transport v2 โ€” control message parser', () => { + test('round-trips a hello frame', () => { + const msg: TransportV2ControlMsg = { + t: 'hello', + protocolVersion: TRANSPORT_V2_PROTOCOL_VERSION, + capabilities: ['streaming-bodies', 'codegen-gateway'] + } + + const wire = stringifyTransportV2ControlMsg(msg) + const parsed = parseTransportV2ControlMsg(wire) + + expect(parsed).toEqual(msg) + }) + + test('round-trips body.open / body.end / body.abort frames', () => { + const open: TransportV2ControlMsg = { + t: 'body.open', + bid: 5, + kind: 'request', + rpcId: 'rpc_42', + contentType: 'application/octet-stream', + contentLength: 1024 + } + const end: TransportV2ControlMsg = { t: 'body.end', bid: 5, kind: 'request' } + const abort: TransportV2ControlMsg = { + t: 'body.abort', + bid: 5, + kind: 'response', + error: 'reader cancelled' + } + + expect(parseTransportV2ControlMsg(stringifyTransportV2ControlMsg(open))).toEqual(open) + expect(parseTransportV2ControlMsg(stringifyTransportV2ControlMsg(end))).toEqual(end) + expect(parseTransportV2ControlMsg(stringifyTransportV2ControlMsg(abort))).toEqual(abort) + }) + + test('rejects payloads missing the type field', () => { + expect(() => parseTransportV2ControlMsg('{}')).toThrow(/missing type field/) + }) + + test('rejects unknown control types (including v1 message kinds)', () => { + expect(() => parseTransportV2ControlMsg('{"t":"rpc.call","id":"x","method":"m","params":[]}')) + .toThrow(/unknown type "rpc\.call"/) + }) + + test('rejects hello/welcome with the wrong protocolVersion', () => { + expect(() => + parseTransportV2ControlMsg('{"t":"hello","protocolVersion":1,"capabilities":[]}') + ).toThrow(/protocolVersion 1 != 2/) + }) + + test('rejects hello/welcome with non-array capabilities', () => { + expect(() => + parseTransportV2ControlMsg('{"t":"welcome","protocolVersion":2,"capabilities":"all"}') + ).toThrow(/capabilities must be an array/) + }) +}) + +describe('transport v2 โ€” capability negotiation', () => { + test('returns the sorted intersection of supported and advertised capabilities', () => { + const result = negotiateTransportV2Capabilities( + ['streaming-bodies', 'codegen-gateway', 'experimental'], + ['codegen-gateway', 'streaming-bodies', 'unknown'] + ) + expect(result).toEqual(['codegen-gateway', 'streaming-bodies']) + }) + + test('returns an empty array when there is no overlap', () => { + expect(negotiateTransportV2Capabilities(['a', 'b'], ['c', 'd'])).toEqual([]) + }) + + test('is deterministic regardless of input order', () => { + const a = negotiateTransportV2Capabilities(['x', 'y', 'z'], ['z', 'y', 'x']) + const b = negotiateTransportV2Capabilities(['z', 'y', 'x'], ['x', 'y', 'z']) + expect(a).toEqual(b) + expect(a).toEqual(['x', 'y', 'z']) + }) + + test('deduplicates repeated capabilities', () => { + const result = negotiateTransportV2Capabilities(['a', 'a', 'b'], ['a', 'b', 'a', 'b']) + expect(result).toEqual(['a', 'b']) + }) +}) diff --git a/packages/devflare/tests/unit/bridge/v2/value-codec.test.ts b/packages/devflare/tests/unit/bridge/v2/value-codec.test.ts new file mode 100644 index 0000000..628e997 --- /dev/null +++ b/packages/devflare/tests/unit/bridge/v2/value-codec.test.ts @@ -0,0 +1,225 @@ +// ============================================================================= +// Transport v2 โ€” Value codec round-trip tests +// ============================================================================= +// Mirrors `tests/unit/bridge/serialization.test.ts` for the v2 value codec. +// JSON-friendly special values (Date/URL/Error/Map/Set/Uint8Array/ArrayBuffer/ +// R2 stubs) round-trip without involving any codec โ€” only stream-bearing +// values (Request/Response/ReadableStream) require a live codec. +// ============================================================================= + +import { describe, test, expect } from 'bun:test' +import { + serializeTransportV2Value, + deserializeTransportV2Value, + serializeTransportV2DOId, + deserializeTransportV2DOId, + TRANSPORT_V2_DO_ID_TYPE, + base64Encode, + base64Decode, + serializeR2Object, + serializeR2ObjectBody +} from '../../../../src/bridge/v2/value-codec' + +async function jsonRoundTrip(value: T): Promise { + const encoded = await serializeTransportV2Value(value) + const transported = JSON.parse(JSON.stringify(encoded)) + return deserializeTransportV2Value(transported) +} + +describe('v2 value codec โ€” special values', () => { + test('round-trips Date', async () => { + const original = new Date('2025-01-02T03:04:05.678Z') + const result = (await jsonRoundTrip(original)) as Date + expect(result).toBeInstanceOf(Date) + expect(result.toISOString()).toBe(original.toISOString()) + }) + + test('round-trips URL', async () => { + const original = new URL('https://example.com/path?q=1#frag') + const result = (await jsonRoundTrip(original)) as URL + expect(result).toBeInstanceOf(URL) + expect(result.href).toBe(original.href) + }) + + test('round-trips Error preserving name/message/stack', async () => { + const original = new TypeError('boom') + const result = (await jsonRoundTrip(original)) as Error + expect(result).toBeInstanceOf(Error) + expect(result.name).toBe('TypeError') + expect(result.message).toBe('boom') + expect(typeof result.stack).toBe('string') + }) + + test('round-trips Map with nested URLs', async () => { + const original = new Map([ + ['home', new URL('https://example.com/')], + ['docs', new URL('https://example.com/docs')] + ]) + const result = (await jsonRoundTrip(original)) as Map + expect(result).toBeInstanceOf(Map) + expect(result.size).toBe(2) + expect(result.get('home')?.href).toBe('https://example.com/') + expect(result.get('docs')?.href).toBe('https://example.com/docs') + }) + + test('round-trips Set with nested Dates', async () => { + const d1 = new Date('2025-05-05T00:00:00.000Z') + const d2 = new Date('2026-06-06T00:00:00.000Z') + const result = (await jsonRoundTrip(new Set([d1, d2]))) as Set + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(2) + const isos = Array.from(result).map((d) => d.toISOString()).sort() + expect(isos).toEqual([d1.toISOString(), d2.toISOString()]) + }) + + test('round-trips Uint8Array via base64', async () => { + const original = new Uint8Array([1, 2, 3, 0, 255, 7]) + const result = (await jsonRoundTrip(original)) as Uint8Array + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual(Array.from(original)) + }) + + test('round-trips ArrayBuffer via base64', async () => { + const bytes = new Uint8Array([10, 20, 30]) + const result = (await jsonRoundTrip(bytes.buffer)) as ArrayBuffer + expect(result).toBeInstanceOf(ArrayBuffer) + expect(Array.from(new Uint8Array(result))).toEqual([10, 20, 30]) + }) + + test('round-trips deeply nested mixed structure', async () => { + const original = { + when: new Date('2025-07-08T09:10:11.000Z'), + tags: new Set(['alpha', 'beta']), + meta: new Map([['root', new URL('https://devflare.dev/')]]), + cause: new Error('kapow') + } + const result = (await jsonRoundTrip(original)) as typeof original + expect(result.when).toBeInstanceOf(Date) + expect(result.when.toISOString()).toBe(original.when.toISOString()) + expect(result.tags).toBeInstanceOf(Set) + expect(Array.from(result.tags).sort()).toEqual(['alpha', 'beta']) + expect(result.meta.get('root')?.href).toBe('https://devflare.dev/') + expect(result.cause).toBeInstanceOf(Error) + expect(result.cause.message).toBe('kapow') + }) + + test('passes through primitives & plain containers untouched', async () => { + expect(await jsonRoundTrip(null)).toBe(null) + expect(await jsonRoundTrip(42)).toBe(42) + expect(await jsonRoundTrip('hello')).toBe('hello') + expect(await jsonRoundTrip([1, 2, 'three'])).toEqual([1, 2, 'three']) + expect(await jsonRoundTrip({ a: 1, b: 'two', c: [3] })).toEqual({ a: 1, b: 'two', c: [3] }) + }) +}) + +describe('v2 value codec โ€” DurableObjectId helpers', () => { + test('serializeTransportV2DOId emits the canonical wire shape', () => { + const fakeId = { toString: () => 'abc123' } as DurableObjectId + const wire = serializeTransportV2DOId(fakeId) + expect(wire).toEqual({ __type: TRANSPORT_V2_DO_ID_TYPE, hex: 'abc123' }) + }) + + test('deserializeTransportV2DOId calls ns.idFromString with the hex', () => { + const calls: string[] = [] + const ns = { + idFromString: (hex: string) => { + calls.push(hex) + return { hex } as unknown as DurableObjectId + } + } as unknown as DurableObjectNamespace + const out = deserializeTransportV2DOId({ __type: TRANSPORT_V2_DO_ID_TYPE, hex: 'deadbeef' }, ns) + expect(calls).toEqual(['deadbeef']) + expect(out).toEqual({ hex: 'deadbeef' } as unknown as DurableObjectId) + }) + + test('deserializeTransportV2DOId rejects unknown shapes', () => { + const ns = { idFromString: () => null } as unknown as DurableObjectNamespace + expect(() => deserializeTransportV2DOId({ __type: 'wrong', hex: 'x' } as unknown as { __type: typeof TRANSPORT_V2_DO_ID_TYPE; hex: string }, ns)).toThrow('Invalid DOId format') + }) +}) + +describe('v2 value codec โ€” base64 helpers', () => { + test('encode/decode round-trips arbitrary bytes', () => { + const bytes = new Uint8Array(256) + for (let i = 0; i < 256; i++) bytes[i] = i + const decoded = base64Decode(base64Encode(bytes)) + expect(Array.from(decoded)).toEqual(Array.from(bytes)) + }) + + test('encodes empty input as empty string', () => { + expect(base64Encode(new Uint8Array(0))).toBe('') + expect(base64Decode('').byteLength).toBe(0) + }) +}) + +describe('v2 value codec โ€” R2 helpers', () => { + test('serializeR2Object emits the metadata-only wire shape', () => { + const obj = { + key: 'k', + version: 'v', + size: 3, + etag: 'e', + httpEtag: 'he', + checksums: {} as R2Checksums, + uploaded: new Date('2025-01-01T00:00:00Z'), + httpMetadata: { contentType: 'text/plain' } as R2HTTPMetadata, + customMetadata: { a: '1' }, + range: undefined, + storageClass: 'Standard' + } as unknown as R2Object + const wire = serializeR2Object(obj) as Record + expect(wire.__type).toBe('R2Object') + expect(wire.key).toBe('k') + expect(wire.uploaded).toBe('2025-01-01T00:00:00.000Z') + }) + + test('serializeR2Object returns null for null input', () => { + expect(serializeR2Object(null)).toBe(null) + }) + + test('serializeR2ObjectBody embeds bodyData as base64', async () => { + const body = new TextEncoder().encode('hello world') + const obj = { + key: 'k', + version: 'v', + size: body.byteLength, + etag: 'e', + httpEtag: 'he', + checksums: {} as R2Checksums, + uploaded: new Date('2025-01-01T00:00:00Z'), + httpMetadata: undefined, + customMetadata: undefined, + range: undefined, + storageClass: 'Standard', + body: new ReadableStream(), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer + } as unknown as R2ObjectBody + const wire = (await serializeR2ObjectBody(obj)) as Record + expect(wire.__type).toBe('R2ObjectBody') + expect(wire.bodyData).toBe(base64Encode(body)) + }) + + test('jsonRoundTrip rebuilds R2Object metadata + R2ObjectBody methods', async () => { + const body = new TextEncoder().encode('hi') + const wire = await serializeR2ObjectBody({ + key: 'k', + version: 'v', + size: body.byteLength, + etag: 'e', + httpEtag: 'he', + checksums: {} as R2Checksums, + uploaded: new Date('2025-01-01T00:00:00Z'), + httpMetadata: { contentType: 'text/plain' } as R2HTTPMetadata, + customMetadata: undefined, + range: undefined, + storageClass: 'Standard', + body: new ReadableStream(), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer + } as unknown as R2ObjectBody) + + const transported = JSON.parse(JSON.stringify(wire)) + const decoded = deserializeTransportV2Value(transported) as R2ObjectBody + expect(decoded.key).toBe('k') + expect(await decoded.text()).toBe('hi') + }) +}) diff --git a/packages/devflare/tests/unit/browser-shim/server.test.ts b/packages/devflare/tests/unit/browser-shim/server.test.ts new file mode 100644 index 0000000..32c61d0 --- /dev/null +++ b/packages/devflare/tests/unit/browser-shim/server.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect } from 'bun:test' +import { + DEFAULT_CHROME_FLAGS, + NO_SANDBOX_FLAGS, + resolveChromeFlags, + createDownloadProgressLogger +} from '../../../src/browser-shim/server' + +describe('browser-shim chrome flags', () => { + test('defaults do not include --no-sandbox', () => { + expect(DEFAULT_CHROME_FLAGS).not.toContain('--no-sandbox') + expect(DEFAULT_CHROME_FLAGS).not.toContain('--disable-setuid-sandbox') + }) + + test('resolveChromeFlags() returns defaults without sandbox disabling flags', () => { + const flags = resolveChromeFlags() + expect(flags).not.toContain('--no-sandbox') + expect(flags).not.toContain('--disable-setuid-sandbox') + }) + + test('resolveChromeFlags({ allowNoSandbox: false }) omits sandbox disabling flags', () => { + const flags = resolveChromeFlags({ allowNoSandbox: false }) + expect(flags).not.toContain('--no-sandbox') + expect(flags).not.toContain('--disable-setuid-sandbox') + }) + + test('resolveChromeFlags({ allowNoSandbox: true }) adds the opt-in flags', () => { + const flags = resolveChromeFlags({ allowNoSandbox: true }) + for (const flag of NO_SANDBOX_FLAGS) { + expect(flags).toContain(flag) + } + for (const flag of DEFAULT_CHROME_FLAGS) { + expect(flags).toContain(flag) + } + }) + + test('core stability flags remain in defaults', () => { + expect(DEFAULT_CHROME_FLAGS).toContain('--disable-dev-shm-usage') + expect(DEFAULT_CHROME_FLAGS).toContain('--disable-gpu') + expect(DEFAULT_CHROME_FLAGS).toContain('--mute-audio') + }) +}) + +describe('browser-shim download progress logger', () => { + function makeLogger() { + const lines: Array<{ level: string; msg: string }> = [] + const record = (level: string) => (msg: unknown) => { + lines.push({ level, msg: String(msg) }) + } + const logger = { + info: record('info'), + warn: record('warn'), + error: record('error'), + debug: record('debug'), + success: record('success') + } as unknown as Parameters[0] + return { logger, lines } + } + + test('emits exactly one "download complete" line for a full progress stream', () => { + const { logger, lines } = makeLogger() + const tracker = createDownloadProgressLogger(logger, 'Chrome') + + tracker.onProgress(0, 100) + tracker.onProgress(25, 100) + tracker.onProgress(50, 100) + tracker.onProgress(100, 100) + // Extra post-complete call should be ignored. + tracker.onProgress(100, 100) + + const completeLines = lines.filter((l) => l.msg.includes('download complete')) + expect(completeLines.length).toBe(1) + expect(tracker.completed).toBe(true) + expect(tracker.progress).toEqual({ bytesReceived: 100, totalBytes: 100 }) + }) + + test('emits exactly one start line regardless of tick count', () => { + const { logger, lines } = makeLogger() + const tracker = createDownloadProgressLogger(logger, 'Chrome') + + for (let i = 0;i <= 100;i += 1) { + tracker.onProgress(i, 100) + } + + const startLines = lines.filter((l) => l.msg.includes('Downloading Chrome')) + const completeLines = lines.filter((l) => l.msg.includes('download complete')) + expect(startLines.length).toBe(1) + expect(completeLines.length).toBe(1) + }) + + test('finalize() completes a dangling in-progress download exactly once', () => { + const { logger, lines } = makeLogger() + const tracker = createDownloadProgressLogger(logger, 'Chrome') + + tracker.onProgress(10, 0) // totalBytes unknown + tracker.finalize() + tracker.finalize() // second call is a no-op + + const completeLines = lines.filter((l) => l.msg.includes('download complete')) + expect(completeLines.length).toBe(1) + }) + + test('finalize() is a no-op when nothing was downloaded', () => { + const { logger, lines } = makeLogger() + const tracker = createDownloadProgressLogger(logger, 'Chrome') + + tracker.finalize() + + expect(lines.length).toBe(0) + expect(tracker.started).toBe(false) + expect(tracker.completed).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/bundler/defaults.test.ts b/packages/devflare/tests/unit/bundler/defaults.test.ts new file mode 100644 index 0000000..8be575a --- /dev/null +++ b/packages/devflare/tests/unit/bundler/defaults.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { createWorkerdBundlerDefaults } from '../../../src/bundler/defaults' + +describe('createWorkerdBundlerDefaults', () => { + test('returns the documented baseline shape', () => { + const d = createWorkerdBundlerDefaults() + expect(d).toEqual({ + platform: 'browser', + defaultTsconfigMode: 'if-present', + sourcemap: false, + minify: false + }) + }) + + test('returns a fresh object each call (callers are free to mutate the spread)', () => { + const a = createWorkerdBundlerDefaults() + const b = createWorkerdBundlerDefaults() + expect(a).not.toBe(b) + expect(a).toEqual(b) + }) + + test('parity for shared keys consumed by both worker- and do-bundler', () => { + const d = createWorkerdBundlerDefaults() + const sharedKeys = ['platform', 'defaultTsconfigMode', 'sourcemap', 'minify'] + for (const k of sharedKeys) { + expect(d).toHaveProperty(k) + } + }) +}) diff --git a/packages/devflare/tests/unit/bundler/do-bundler.test.ts b/packages/devflare/tests/unit/bundler/do-bundler.test.ts new file mode 100644 index 0000000..ab2bb97 --- /dev/null +++ b/packages/devflare/tests/unit/bundler/do-bundler.test.ts @@ -0,0 +1,121 @@ +// ============================================================================= +// DO Bundler Tests +// ============================================================================= + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { join } from 'pathe' +import { createDOBundler } from '../../../src/bundler/do-bundler' + +const TEST_DIR = join(import.meta.dirname, '../.fixtures/do-bundler') + +describe('createDOBundler', () => { + beforeEach(async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true + } + }, null, '\t')) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('applies user Rolldown plugins to Durable Object bundles', async () => { + await writeFile(join(TEST_DIR, 'src/Greeting.svelte'), ` +

Hello from Svelte

+ `.trim()) + + await writeFile(join(TEST_DIR, 'src/do.greeter.ts'), ` +import { DurableObject } from 'cloudflare:workers' +import renderGreeting from './Greeting.svelte' + +export class Greeter extends DurableObject { + async fetch(): Promise { + return new Response(renderGreeting()) + } +} + `.trim()) + + const bundler = createDOBundler({ + cwd: TEST_DIR, + pattern: 'src/do.*.ts', + outDir: join(TEST_DIR, '.devflare/do-bundles'), + sourcemap: true, + rolldownOptions: { + plugins: [{ + name: 'test-svelte-transform', + transform(code, id) { + if (!id.endsWith('.svelte')) { + return null + } + + const heading = code.match(/

(.*?)<\/h1>/)?.[1] ?? 'Hello from Svelte' + + return { + code: `export default function renderGreeting() { return ${JSON.stringify(heading)} }`, + map: null + } + } + }] + } + }) + + const result = await bundler.build() + await bundler.close() + + expect(result.errors).toEqual([]) + + const bundlePath = result.bundles.get('GREETER') + expect(bundlePath).toBeDefined() + + if (!bundlePath) { + throw new Error('Expected GREETER bundle path to be generated') + } + + const output = await readFile(bundlePath, 'utf-8') + expect(output).toContain('Hello from Svelte') + + const sourceMap = await stat(`${bundlePath}.map`) + expect(sourceMap.isFile()).toBe(true) + }) + + test('injects the Durable Object event wrapper into bundled outputs', async () => { + await writeFile(join(TEST_DIR, 'src/do.logger.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Logger extends DurableObject { + async fetch({ request }): Promise { + return new Response(request.url) + } +} + `.trim()) + + const bundler = createDOBundler({ + cwd: TEST_DIR, + pattern: 'src/do.*.ts', + outDir: join(TEST_DIR, '.devflare/do-bundles') + }) + + const result = await bundler.build() + await bundler.close() + + expect(result.errors).toEqual([]) + + const bundlePath = result.bundles.get('LOGGER') + expect(bundlePath).toBeDefined() + + if (!bundlePath) { + throw new Error('Expected LOGGER bundle path to be generated') + } + + const output = await readFile(bundlePath, 'utf-8') + expect(output).toContain('createDurableObjectFetchEvent') + expect(output).toContain('runWithEventContext') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/bundler/merge-aliases.test.ts b/packages/devflare/tests/unit/bundler/merge-aliases.test.ts new file mode 100644 index 0000000..a4be89c --- /dev/null +++ b/packages/devflare/tests/unit/bundler/merge-aliases.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { mergeAliases } from '../../../src/bundler' + +describe('mergeAliases', () => { + test('user alias overrides framework default on duplicate find keys', () => { + const frameworkDefaults = [ + { find: 'debug', replacement: '/framework/debug-shim.js' }, + { find: 'devflare', replacement: '/framework/devflare.js' } + ] + const userAliases = [ + { find: 'debug', replacement: '/user/my-debug.js' } + ] + + const merged = mergeAliases(userAliases, frameworkDefaults) + + const debugEntry = merged.find((entry) => entry.find === 'debug') + expect(debugEntry?.replacement).toBe('/user/my-debug.js') + + // Framework default for non-overridden key is kept + const devflareEntry = merged.find((entry) => entry.find === 'devflare') + expect(devflareEntry?.replacement).toBe('/framework/devflare.js') + + // No duplicate `debug` entry + expect(merged.filter((entry) => entry.find === 'debug')).toHaveLength(1) + }) + + test('preserves ordering of user entries so regex specificity is predictable', () => { + const frameworkDefaults = [ + { find: 'shared', replacement: '/framework/shared.js' } + ] + const specificRegex = /^@app\/ui\// + const broadRegex = /^@app\// + const userAliases = [ + { find: specificRegex, replacement: '/user/ui.js' }, + { find: broadRegex, replacement: '/user/app.js' }, + { find: 'utils', replacement: '/user/utils.js' } + ] + + const merged = mergeAliases(userAliases, frameworkDefaults) + + // Framework defaults come first, then user entries in user's order + expect(merged).toEqual([ + { find: 'shared', replacement: '/framework/shared.js' }, + { find: specificRegex, replacement: '/user/ui.js' }, + { find: broadRegex, replacement: '/user/app.js' }, + { find: 'utils', replacement: '/user/utils.js' } + ]) + }) +}) diff --git a/packages/devflare/tests/unit/bundler/worker-bundler.test.ts b/packages/devflare/tests/unit/bundler/worker-bundler.test.ts new file mode 100644 index 0000000..8875f47 --- /dev/null +++ b/packages/devflare/tests/unit/bundler/worker-bundler.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { join, isAbsolute } from 'pathe' +import { bundleWorkerEntry } from '../../../src/bundler' +import { configSchema } from '../../../src/config/schema' +import { prepareComposedWorkerEntrypoint } from '../../../src/worker-entry/composed-worker' + +const TEST_DIR = join(import.meta.dirname, '../.fixtures/worker-bundler') + +describe('bundleWorkerEntry', () => { + beforeEach(async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true + } + }, null, '\t')) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('applies user Rolldown plugins to the composed main worker bundle', async () => { + await writeFile(join(TEST_DIR, 'src', 'Greeting.svelte'), ` +

Hello from Svelte

+ `.trim()) + + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +import renderGreeting from './Greeting.svelte' + +export async function fetch(): Promise { + return new Response(renderGreeting()) +} + `.trim()) + + const config = configSchema.parse({ + name: 'worker-bundler-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry && isAbsolute(composedEntry)).toBe(true) + + if (!composedEntry) { + throw new Error('Expected composed worker entry to be generated') + } + + const bundlePath = await bundleWorkerEntry({ + cwd: TEST_DIR, + inputFile: composedEntry, + outFile: join(TEST_DIR, '.devflare', 'worker-entrypoints', 'main.js'), + sourcemap: true, + rolldownOptions: { + plugins: [{ + name: 'test-svelte-transform', + transform(code, id) { + if (!id.endsWith('.svelte')) { + return null + } + + const heading = code.match(/

(.*?)<\/h1>/)?.[1] ?? 'Hello from Svelte' + + return { + code: `export default function renderGreeting() { return ${JSON.stringify(heading)} }`, + map: null + } + } + }] + } + }) + + const output = await readFile(bundlePath, 'utf-8') + expect(output).toContain('Hello from Svelte') + + const sourceMap = await stat(`${bundlePath}.map`) + expect(sourceMap.isFile()).toBe(true) + }) + + test('bundles bare devflare root imports through the worker-safe entry', async () => { + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +import { env } from 'devflare' + +export async function fetch(): Promise { + return new Response(String(env.MESSAGE ?? 'ok')) +} + `.trim()) + + const config = configSchema.parse({ + name: 'worker-bundler-root-import-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + }, + vars: { + MESSAGE: 'ok' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry && isAbsolute(composedEntry)).toBe(true) + + if (!composedEntry) { + throw new Error('Expected composed worker entry to be generated') + } + + const bundlePath = await bundleWorkerEntry({ + cwd: TEST_DIR, + inputFile: composedEntry, + outFile: join(TEST_DIR, '.devflare', 'worker-entrypoints', 'main.js') + }) + + const output = await readFile(bundlePath, 'utf-8') + expect(output).not.toMatch(/\bimport\s*\(/) + expect(output).not.toContain('./commands/') + }) + + test('rewrites third-party dynamic import helpers into worker-safe bundle code', async () => { + await mkdir(join(TEST_DIR, 'node_modules', 'example-runtime'), { + recursive: true + }) + await writeFile(join(TEST_DIR, 'node_modules', 'example-runtime', 'package.json'), JSON.stringify({ + name: 'example-runtime', + type: 'module' + }, null, '\t')) + await writeFile(join(TEST_DIR, 'node_modules', 'example-runtime', 'index.js'), ` +const importRuntimeModule = (module_name) => import( + /* @vite-ignore */ + module_name +) + +export async function loadAsyncHooks() { + const hooks = await import('node:async_hooks') + return typeof hooks.AsyncLocalStorage === 'function' +} + +export async function loadCrypto() { + return (await importRuntimeModule('node:crypto')).webcrypto ? 'crypto-ready' : 'crypto-missing' +} + `.trim()) + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +import { loadAsyncHooks, loadCrypto } from 'example-runtime' + +export async function fetch(): Promise { + return new Response(JSON.stringify({ + als: await loadAsyncHooks(), + crypto: await loadCrypto() + })) +} + `.trim()) + + const config = configSchema.parse({ + name: 'worker-bundler-dynamic-import-helper-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry && isAbsolute(composedEntry)).toBe(true) + + if (!composedEntry) { + throw new Error('Expected composed worker entry to be generated') + } + + const bundlePath = await bundleWorkerEntry({ + cwd: TEST_DIR, + inputFile: composedEntry, + outFile: join(TEST_DIR, '.devflare', 'worker-entrypoints', 'main.js') + }) + + const output = await readFile(bundlePath, 'utf-8') + expect(output).not.toMatch(new RegExp('\\bimport\\s*\\(')) + expect(output).toContain('node:async_hooks') + expect(output).toContain('Unsupported dynamic import in Devflare worker bundle') + }) + + test('fails early when a worker bundle still contains runtime-computed dynamic imports', async () => { + await mkdir(join(TEST_DIR, 'node_modules', 'example-runtime'), { + recursive: true + }) + await writeFile(join(TEST_DIR, 'node_modules', 'example-runtime', 'package.json'), JSON.stringify({ + name: 'example-runtime', + type: 'module' + }, null, '\t')) + await writeFile(join(TEST_DIR, 'node_modules', 'example-runtime', 'index.js'), ` +export async function loadRuntimeModule(moduleName) { + return import(moduleName) +} + `.trim()) + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +import { loadRuntimeModule } from 'example-runtime' + +export async function fetch(request: Request): Promise { + const moduleName = new URL(request.url).searchParams.get('module') ?? 'node:crypto' + await loadRuntimeModule(moduleName) + return new Response('ok') +} + `.trim()) + + const config = configSchema.parse({ + name: 'worker-bundler-dynamic-import-error-test', + compatibilityDate: '2026-03-17', + files: { + fetch: 'src/fetch.ts' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry && isAbsolute(composedEntry)).toBe(true) + + if (!composedEntry) { + throw new Error('Expected composed worker entry to be generated') + } + + await expect(bundleWorkerEntry({ + cwd: TEST_DIR, + inputFile: composedEntry, + outFile: join(TEST_DIR, '.devflare', 'worker-entrypoints', 'main.js') + })).rejects.toThrow('Devflare worker bundles cannot contain unresolved dynamic import() expressions') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cli/account.test.ts b/packages/devflare/tests/unit/cli/account.test.ts new file mode 100644 index 0000000..26fb215 --- /dev/null +++ b/packages/devflare/tests/unit/cli/account.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { runAccountCommand } from '../../../src/cli/commands/account' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger } from '../../helpers/mock-logger' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN +const originalAccountId = process.env.CLOUDFLARE_ACCOUNT_ID + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } + if (originalAccountId === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = originalAccountId + } +}) + +describe('account command', () => { + test('falls back to the configured account when all-account enumeration is unavailable', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + process.env.CLOUDFLARE_ACCOUNT_ID = 'acc_123' + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts?page=1&per_page=50')) { + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 6003, message: 'Invalid request headers' }], + messages: [], + result: [] + }), { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + }) + } + + if (url.endsWith('/accounts/acc_123')) { + return jsonResponse({ + id: 'acc_123', + name: 'Configured Account', + type: 'standard' + }) + } + + if (url.includes('/storage/kv/namespaces')) { + throw new Error('KV access is not required for this fallback path') + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runAccountCommand( + { + command: 'account', + args: [], + options: {} + }, + logger as any, + {} + ) + + expect(result.exitCode).toBe(0) + expect(logger.messages.some((message) => message.args.join(' ').includes('Using the configured account directly'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Configured Account'))).toBe(true) + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cli/build-artifacts.test.ts b/packages/devflare/tests/unit/cli/build-artifacts.test.ts new file mode 100644 index 0000000..b79e08e --- /dev/null +++ b/packages/devflare/tests/unit/cli/build-artifacts.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, mock, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { + cleanupViteBuildOutputs, + createDeferredCleanupPath, + findWorkspaceLocalBinary, + isolateViteBuildOutputPaths, + isRunningUnderBun, + removePathWithRetries, + resolveLocalViteExecutable, + getViteBuildCleanupTargets +} from '../../../src/cli/commands/build-artifacts' +import type { FileSystem } from '../../../src/cli/dependencies' +import type { WranglerConfig } from '../../../src/config/compiler' +import { createLogger, renderMessages } from '../../helpers/mock-logger' + +describe('build artifact cleanup helpers', () => { + test('deduplicates worker cleanup when the main entry lives inside assets.directory', () => { + const cleanupTargets = getViteBuildCleanupTargets('C:/project', { + name: 'documentation', + compatibility_date: '2026-04-08', + main: '.adapter-cloudflare/_worker.js', + assets: { + directory: '.adapter-cloudflare', + binding: 'ASSETS' + } + } satisfies WranglerConfig) + + expect(cleanupTargets).toEqual(['C:/project/.adapter-cloudflare']) + }) + + test('removes Vite build outputs before rebuilding', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-build-artifacts-')) + const outputDir = join(projectDir, '.adapter-cloudflare') + const workerPath = join(outputDir, '_worker.js') + + try { + await mkdir(outputDir, { recursive: true }) + await writeFile(workerPath, 'export default {}') + + await cleanupViteBuildOutputs(projectDir, { + name: 'documentation', + compatibility_date: '2026-04-08', + main: '.adapter-cloudflare/_worker.js', + assets: { + directory: '.adapter-cloudflare', + binding: 'ASSETS' + } + } satisfies WranglerConfig, createLogger() as never) + + expect(await Bun.file(outputDir).exists()).toBe(false) + expect(await Bun.file(workerPath).exists()).toBe(false) + } finally { + await rm(projectDir, { + recursive: true, + force: true + }) + } + }) + + test('creates stable deferred cleanup paths when moving locked outputs aside', () => { + expect( + createDeferredCleanupPath('C:/project/.adapter-cloudflare', 'fixed-suffix') + ).toBe('C:/project/.adapter-cloudflare.devflare-stale-fixed-suffix') + }) + + test('isolates Vite-backed adapter outputs inside .devflare during builds', () => { + const isolated = isolateViteBuildOutputPaths('C:/project', { + name: 'documentation', + compatibility_date: '2026-04-08', + main: '.adapter-cloudflare/_worker.js', + assets: { + directory: '.adapter-cloudflare', + binding: 'ASSETS' + } + } satisfies WranglerConfig) + + expect(isolated.assets?.directory).toBe('.devflare/vite-build-output/.adapter-cloudflare') + expect(isolated.main).toBe('.devflare/vite-build-output/.adapter-cloudflare/_worker.js') + }) + + test('moves locked Vite outputs aside after repeated retryable cleanup failures', async () => { + const logger = createLogger() + const busyError = Object.assign(new Error('busy'), { + code: 'EBUSY' + }) + const access = mock(async (_path: string) => { }) + const rename = mock(async (_oldPath: string, _newPath: string) => { }) + const rm = mock(async (targetPath: string, _options: { recursive: boolean; force: boolean }) => { + if (targetPath.includes('.devflare-stale-')) { + return + } + + throw busyError + }) + + await expect( + removePathWithRetries( + 'C:/project/.adapter-cloudflare', + logger as never, + 2, + { + access, + rename, + rm + } + ) + ).resolves.toBeUndefined() + + expect(rename).toHaveBeenCalledTimes(1) + expect(rename.mock.calls[0]?.[0]).toBe('C:/project/.adapter-cloudflare') + expect(String(rename.mock.calls[0]?.[1])).toContain( + 'C:/project/.adapter-cloudflare.devflare-stale-' + ) + expect( + renderMessages(logger).some((message) => + message.includes('Moved locked build output aside to') + ) + ).toBe(true) + }) + + test('continues without pre-clean when a locked output cannot be moved aside', async () => { + const logger = createLogger() + const busyError = Object.assign(new Error('busy'), { + code: 'EBUSY' + }) + const access = mock(async () => { }) + const rename = mock(async () => { + throw busyError + }) + const rm = mock(async () => { + throw busyError + }) + + await expect( + removePathWithRetries( + 'C:/project/.adapter-cloudflare', + logger as never, + 2, + { + access, + rename, + rm + } + ) + ).resolves.toBeUndefined() + + expect( + renderMessages(logger).some((message) => + message.includes('Continuing build without pre-clean for C:/project/.adapter-cloudflare') + ) + ).toBe(true) + }) + + test('rethrows non-retryable cleanup errors', async () => { + const logger = createLogger() + const deniedError = Object.assign(new Error('denied'), { + code: 'EACCES' + }) + const access = mock(async () => { }) + const rename = mock(async () => { }) + const rm = mock(async () => { + throw deniedError + }) + + await expect( + removePathWithRetries( + 'C:/project/.adapter-cloudflare', + logger as never, + 2, + { + access, + rename, + rm + } + ) + ).rejects.toMatchObject({ + code: 'EACCES' + }) + }) +}) + +describe('vite executable resolution', () => { + test('isRunningUnderBun is true under the bun:test runtime', () => { + // This whole test suite runs under Bun. The helper exists precisely so the + // build pipeline can branch on this โ€” keep it pinned so a future regression + // to a Node-only check is caught. + expect(isRunningUnderBun()).toBe(true) + }) + + test('findWorkspaceLocalBinary walks up to find a hoisted node_modules entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-')) + try { + const hoistedBin = join(root, 'node_modules', 'vite', 'bin', 'vite.js') + await mkdir(join(root, 'node_modules', 'vite', 'bin'), { recursive: true }) + await writeFile(hoistedBin, '#!/usr/bin/env node\n') + + const childCwd = join(root, 'apps', 'portal') + await mkdir(childCwd, { recursive: true }) + + const fs = await import('node:fs/promises') + const found = await findWorkspaceLocalBinary(childCwd, fs as unknown as FileSystem, [ + 'vite', + 'bin', + 'vite.js' + ]) + + expect(found).toBe(hoistedBin) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('findWorkspaceLocalBinary returns null when no workspace match exists', async () => { + const root = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-empty-')) + try { + const fs = await import('node:fs/promises') + const found = await findWorkspaceLocalBinary(root, fs as unknown as FileSystem, [ + 'vite', + 'bin', + 'vite.js' + ]) + + expect(found).toBeNull() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('resolveLocalViteExecutable prefers the workspace-local path over a Bun cache realpath', async () => { + // Regression: under Bun on Windows, `resolvePackageSpecifier('vite/bin/vite.js')` can + // land on `C:\Users\โ€ฆ\.bun\install\cache\vite@x.y.z@@@1\bin\vite.js`, which breaks + // Vite 8's resolution of `rolldown` because Node no longer sees the workspace's + // hoisted node_modules from that physical path. + const root = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-prefers-')) + try { + const workspaceBin = join(root, 'node_modules', 'vite', 'bin', 'vite.js') + await mkdir(join(root, 'node_modules', 'vite', 'bin'), { recursive: true }) + await writeFile(workspaceBin, '#!/usr/bin/env node\n') + + const childCwd = join(root, 'apps', 'portal') + await mkdir(childCwd, { recursive: true }) + + const fs = await import('node:fs/promises') + const resolved = await resolveLocalViteExecutable(childCwd, fs as unknown as FileSystem) + + expect(resolved).toBe(workspaceBin) + expect(resolved.includes('.bun')).toBe(false) + expect(resolved.includes('install/cache')).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test('resolveLocalViteExecutable throws an actionable error when no Vite is installed', async () => { + const root = await mkdtemp(join(tmpdir(), 'devflare-vite-resolve-missing-')) + try { + // Provide a FileSystem whose `access` always fails so neither the workspace + // walk nor the package-specifier fallback finds anything. + const fs: Pick = { + access: async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + } + + await expect( + resolveLocalViteExecutable(root, fs as FileSystem) + ).rejects.toThrow(/Could not resolve a local Vite CLI/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cli/build-manifest.test.ts b/packages/devflare/tests/unit/cli/build-manifest.test.ts new file mode 100644 index 0000000..aa6da4c --- /dev/null +++ b/packages/devflare/tests/unit/cli/build-manifest.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { + BUILD_MANIFEST_VERSION, + compareManifests, + createBuildManifest, + formatDriftWarning, + hashSourceConfig, + readBuildManifest, + summarizeBindings, + writeBuildManifest +} from '../../../src/cli/build-manifest' +import type { DevflareConfig } from '../../../src/config' + +const baseConfig: DevflareConfig = { + name: 'my-worker', + compatibilityDate: '2026-04-01', + compatibilityFlags: [], + bindings: { + kv: { CACHE: { name: 'cache-kv' } }, + d1: { DB: { name: 'main-db' } } + } +} + +describe('build-manifest', () => { + test('hashSourceConfig produces stable hash and ignores accountId drift', () => { + const a = hashSourceConfig(baseConfig) + const b = hashSourceConfig({ ...baseConfig, accountId: 'account-A' }) + const c = hashSourceConfig({ ...baseConfig, accountId: 'account-B' }) + expect(a).toEqual(b) + expect(b).toEqual(c) + }) + + test('hashSourceConfig changes when bindings change', () => { + const a = hashSourceConfig(baseConfig) + const b = hashSourceConfig({ + ...baseConfig, + bindings: { ...baseConfig.bindings, kv: { CACHE2: { name: 'cache-kv-2' } } } + }) + expect(a).not.toEqual(b) + }) + + test('summarizeBindings collects sorted binding keys per type', () => { + const summary = summarizeBindings({ + ...baseConfig, + tailConsumers: [ + 'observability-tail' + ], + bindings: { + ...baseConfig.bindings, + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { limit: 100, period: 60 } + } + }, + versionMetadata: { binding: 'CF_VERSION_METADATA' }, + workerLoaders: { + LOADER: {} + }, + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123' + } + }, + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers' + } + }, + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow' + } + }, + pipelines: { + EVENTS: { + pipeline: 'events-stream' + } + }, + images: { + IMAGES: { + remote: true + } + }, + media: { + MEDIA: { + remote: true + } + }, + artifacts: { + ARTIFACTS: { + namespace: 'default' + } + }, + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + } + } + }) + expect(summary.kv).toEqual(['CACHE']) + expect(summary.d1).toEqual(['DB']) + expect(summary.r2).toEqual([]) + expect(summary.rateLimits).toEqual(['MY_RATE_LIMITER']) + expect(summary.versionMetadata).toEqual(['CF_VERSION_METADATA']) + expect(summary.workerLoaders).toEqual(['LOADER']) + expect(summary.mtlsCertificates).toEqual(['API_CERT']) + expect(summary.dispatchNamespaces).toEqual(['DISPATCHER']) + expect(summary.workflows).toEqual(['ORDER_WORKFLOW']) + expect(summary.pipelines).toEqual(['EVENTS']) + expect(summary.images).toEqual(['IMAGES']) + expect(summary.media).toEqual(['MEDIA']) + expect(summary.artifacts).toEqual(['ARTIFACTS']) + expect(summary.secretsStore).toEqual(['API_TOKEN']) + expect(summary.tailConsumers).toEqual(['observability-tail']) + }) + + test('createBuildManifest stamps version + target + bindings snapshot', () => { + const manifest = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0-test', + intendedTarget: { preview: false } + }) + expect(manifest.manifestVersion).toBe(BUILD_MANIFEST_VERSION) + expect(manifest.devflareVersion).toBe('1.0.0-test') + expect(manifest.intendedTarget.preview).toBe(false) + expect(manifest.bindingsSnapshot.kv).toEqual(['CACHE']) + }) + + test('writeBuildManifest and readBuildManifest round-trip on disk', async () => { + const dir = await mkdtemp(join(tmpdir(), 'devflare-manifest-')) + try { + const manifest = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0-test', + intendedTarget: { preview: false } + }) + const path = await writeBuildManifest(dir, manifest) + expect(path.endsWith('manifest.json')).toBe(true) + const read = await readBuildManifest(dir) + expect(read?.sourceConfigHash).toBe(manifest.sourceConfigHash) + expect(read?.devflareVersion).toBe('1.0.0-test') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('readBuildManifest returns null when no manifest exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'devflare-manifest-empty-')) + try { + const read = await readBuildManifest(dir) + expect(read).toBeNull() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('compareManifests detects config drift, version drift, and target drift', () => { + const previous = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0', + intendedTarget: { preview: true, previewScope: 'pr-1' } + }) + const current = createBuildManifest( + { + ...baseConfig, + bindings: { + ...baseConfig.bindings, + r2: { ASSETS: 'assets-bucket' } + } + }, + { + devflareVersion: '1.0.1', + intendedTarget: { preview: false } + } + ) + const drift = compareManifests(previous, current) + expect(drift.configChanged).toBe(true) + expect(drift.versionChanged).toBe(true) + expect(drift.targetChanged).toBe(true) + expect(drift.bindingsAdded).toContain('r2:ASSETS') + expect(drift.bindingsRemoved).toEqual([]) + }) + + test('formatDriftWarning returns null when no drift', () => { + const m = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0', + intendedTarget: { preview: false } + }) + const drift = compareManifests(m, m) + expect(formatDriftWarning(drift)).toBeNull() + }) + + test('formatDriftWarning surfaces preview->production flip clearly', () => { + const built = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0', + intendedTarget: { preview: true, previewScope: 'pr-1' } + }) + const deployed = createBuildManifest(baseConfig, { + devflareVersion: '1.0.0', + intendedTarget: { preview: false } + }) + const warning = formatDriftWarning(compareManifests(built, deployed)) + expect(warning).not.toBeNull() + expect(warning).toContain('deployment target differs') + expect(warning).toContain('preview') + expect(warning).toContain('production') + }) +}) diff --git a/packages/devflare/tests/unit/cli/cli.test.ts b/packages/devflare/tests/unit/cli/cli.test.ts new file mode 100644 index 0000000..a21dbaa --- /dev/null +++ b/packages/devflare/tests/unit/cli/cli.test.ts @@ -0,0 +1,375 @@ +// ============================================================================= +// CLI Tests โ€” Command structure and execution +// ============================================================================= + +import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test' +import { parseArgs, runCli } from '../../../src/cli/index' +import { getPackageVersion } from '../../../src/cli/package-metadata' + +describe('parseArgs', () => { + test('parses empty args', () => { + const result = parseArgs([]) + expect(result.command).toBe('help') + }) + + test('parses help flag', () => { + expect(parseArgs(['--help']).command).toBe('help') + expect(parseArgs(['-h']).command).toBe('help') + }) + + test('keeps command-specific help attached to the current command', () => { + const result = parseArgs(['previews', '--help']) + expect(result.command).toBe('previews') + expect(result.options.help).toBe(true) + }) + + test('parses version flag', () => { + expect(parseArgs(['--version']).command).toBe('version') + expect(parseArgs(['-v']).command).toBe('version') + }) + + test('parses help and version commands directly', () => { + expect(parseArgs(['help']).command).toBe('help') + expect(parseArgs(['version']).command).toBe('version') + }) + + test('parses help topics after the help command', () => { + const result = parseArgs(['help', 'previews']) + expect(result.command).toBe('help') + expect(result.args).toEqual(['previews']) + }) + + test('parses init command', () => { + const result = parseArgs(['init']) + expect(result.command).toBe('init') + expect(result.args).toEqual([]) + }) + + test('parses init with project name', () => { + const result = parseArgs(['init', 'my-project']) + expect(result.command).toBe('init') + expect(result.args).toEqual(['my-project']) + }) + + test('parses dev command', () => { + const result = parseArgs(['dev']) + expect(result.command).toBe('dev') + }) + + test('parses dev with port flag', () => { + const result = parseArgs(['dev', '--port', '3000']) + expect(result.command).toBe('dev') + expect(result.options.port).toBe('3000') + }) + + test('parses build command', () => { + const result = parseArgs(['build']) + expect(result.command).toBe('build') + }) + + test('parses deploy command', () => { + const result = parseArgs(['deploy']) + expect(result.command).toBe('deploy') + }) + + test('parses deploy production flags', () => { + const result = parseArgs(['deploy', '--prod']) + expect(result.command).toBe('deploy') + expect(result.options.prod).toBe(true) + }) + + test('parses bare deploy preview flags', () => { + const result = parseArgs(['deploy', '--preview']) + expect(result.command).toBe('deploy') + expect(result.options.preview).toBe(true) + }) + + test('parses deploy named preview target', () => { + const result = parseArgs(['deploy', '--preview', 'pr-1']) + expect(result.command).toBe('deploy') + expect(result.options.preview).toBe('pr-1') + }) + + test('parses deploy preview branch metadata flags', () => { + const result = parseArgs(['deploy', '--preview', '--branch-name', 'feature/branch']) + expect(result.command).toBe('deploy') + expect(result.options.preview).toBe(true) + expect(result.options['branch-name']).toBe('feature/branch') + }) + + test('parses types command', () => { + const result = parseArgs(['types']) + expect(result.command).toBe('types') + }) + + test('parses doctor command', () => { + const result = parseArgs(['doctor']) + expect(result.command).toBe('doctor') + }) + + test('parses tokens command', () => { + const result = parseArgs(['tokens', 'bootstrap-token', '--new', 'preview']) + expect(result.command).toBe('tokens') + expect(result.args).toEqual(['bootstrap-token']) + expect(result.options.new).toBe('preview') + }) + + test('parses token roll command', () => { + const result = parseArgs(['tokens', 'bootstrap-token', '--roll', 'preview']) + expect(result.command).toBe('tokens') + expect(result.args).toEqual(['bootstrap-token']) + expect(result.options.roll).toBe('preview') + }) + + test('parses secrets command', () => { + const result = parseArgs([ + 'secrets', + '--local', + '--store', + 'store-123', + '--name', + 'api-token', + '--value', + 'local-secret' + ]) + + expect(result.command).toBe('secrets') + expect(result.options.local).toBe(true) + expect(result.options.store).toBe('store-123') + expect(result.options.name).toBe('api-token') + expect(result.options.value).toBe('local-secret') + }) + + test('parses login command', () => { + const result = parseArgs(['login', '--force']) + expect(result.command).toBe('login') + expect(result.options.force).toBe(true) + }) + + test('parses previews command', () => { + const result = parseArgs(['previews', 'cleanup', '--apply']) + expect(result.command).toBe('previews') + expect(result.args).toEqual(['cleanup']) + expect(result.options.apply).toBe(true) + }) + + test('parses productions command', () => { + const result = parseArgs(['productions', 'versions', '--worker', 'demo-worker']) + expect(result.command).toBe('productions') + expect(result.args).toEqual(['versions']) + expect(result.options.worker).toBe('demo-worker') + }) + + test('parses worker rename command', () => { + const result = parseArgs(['worker', 'rename', 'documentation', '--to', 'devflare-documentation']) + expect(result.command).toBe('worker') + expect(result.args).toEqual(['rename', 'documentation']) + expect(result.options.to).toBe('devflare-documentation') + }) + + test('parses config print command', () => { + const result = parseArgs(['config', 'print', '--json', '--format', 'wrangler']) + expect(result.command).toBe('config') + expect(result.args).toEqual(['print']) + expect(result.options.json).toBe(true) + expect(result.options.format).toBe('wrangler') + }) + + test('parses global flags', () => { + const result = parseArgs(['dev', '--config', 'custom.config.ts', '--debug']) + expect(result.command).toBe('dev') + expect(result.options.config).toBe('custom.config.ts') + expect(result.options.debug).toBe(true) + }) + + test('unknown command defaults to help', () => { + const result = parseArgs(['unknown-command']) + expect(result.command).toBe('help') + expect(result.unknownCommand).toBe('unknown-command') + }) +}) + +describe('runCli', () => { + test('returns exit code 0 for help', async () => { + const result = await runCli(['--help'], { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toContain('devflare Config compiler + CLI orchestrator for Cloudflare Workers') + expect(result.output).toContain('devflare [options]') + expect(result.output).toContain('previews โ€” Inspect and clean dedicated preview Workers and scopes') + expect(result.output).toContain('productions โ€” Inspect and manage live production Workers and deployments') + expect(result.output).toContain('Use `devflare --help` or `devflare help `') + expect(result.output).toContain('devflare help deploy') + }) + + test('requires an explicit deploy target from the CLI', async () => { + const result = await runCli(['deploy'], { silent: true }) + expect(result.exitCode).toBe(1) + }) + + test('shows deploy help with explicit preview targeting syntax', async () => { + const result = await runCli(['deploy', '--help'], { silent: true }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('devflare deploy --preview [--config ] [--build ] [--message ] [--tag ]') + expect(result.output).toContain('devflare deploy --preview [--config ] [--build ] [--branch-name ] [--message ] [--tag ]') + expect(result.output).toContain('--preview โ€” Deploy a named preview scope such as `next` or `pr-1`') + expect(result.output).toContain('--build โ€” Reuse an existing build artifact') + }) + + test('shows preview cleanup help', async () => { + const result = await runCli(['previews', 'cleanup', '--help'], { silent: true }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('devflare previews cleanup [--config ] [--env ] [--scope | --all] [--account ] [--apply]') + expect(result.output).toContain('--scope โ€” Clean one preview scope instead of the default `preview` scope') + expect(result.output).not.toContain('preview registry') + }) + + test('shows the same detailed help for `help ` and ` --help`', async () => { + const viaHelpCommand = await runCli(['help', 'previews'], { silent: true }) + const viaFlag = await runCli(['previews', '--help'], { silent: true }) + + expect(viaHelpCommand.exitCode).toBe(0) + expect(viaFlag.exitCode).toBe(0) + expect(viaHelpCommand.output).toBe(viaFlag.output) + expect(viaFlag.output).toContain('devflare previews Inspect and clean dedicated preview Worker scopes') + expect(viaFlag.output).toContain('devflare previews cleanup [--config ] [--env ] [--scope | --all] [--account ] [--apply]') + expect(viaFlag.output).toContain('--scope ') + expect(viaFlag.output).toContain('`cleanup` removes preview-only Cloudflare resources for the targeted scope and also deletes dedicated preview-scope Worker scripts') + }) + + test('shows nested help for preview cleanup', async () => { + const result = await runCli(['previews', 'cleanup', '--help'], { silent: true }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('devflare previews cleanup Delete preview-only Worker scripts and preview-scoped Cloudflare resources') + expect(result.output).toContain('--scope โ€” Clean one preview scope instead of the default `preview` scope') + expect(result.output).toContain('--all โ€” Clean every live preview scope Devflare can discover for the current worker family') + expect(result.output).toContain('--apply โ€” Apply the cleanup instead of doing a dry run') + expect(result.output).toContain('Dedicated preview Worker scripts are candidates only when their names resolve to the targeted preview scope') + }) + + test('shows nested help for worker rename', async () => { + const result = await runCli(['worker', 'rename', '--help'], { silent: true }) + + expect(result.exitCode).toBe(0) + expect(result.output).toContain('devflare worker rename Rename a Worker and sync the matching config') + expect(result.output).toContain('devflare worker rename --to [--config ] [--account ]') + }) + + test('resolves the correct help page even when positional arguments are already present', async () => { + const checks = [ + { + argv: ['worker', 'rename', 'documentation', '--help'], + snippet: 'devflare worker rename Rename a Worker and sync the matching config' + }, + { + argv: ['remote', 'enable', '45', '--help'], + snippet: 'devflare remote enable Enable remote test mode' + }, + { + argv: ['account', 'limits', 'set', 'ai-requests', '50', '--help'], + snippet: 'devflare account limits set Set one Devflare usage limit' + }, + { + argv: ['tokens', 'bootstrap-token', '--help'], + snippet: 'devflare tokens Manage Devflare-managed Cloudflare API tokens' + } + ] + + for (const check of checks) { + const result = await runCli(check.argv, { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toContain(check.snippet) + } + }) + + test('provides detailed help pages for every top-level command', async () => { + const commandChecks = [ + { argv: ['init', '--help'], snippet: 'devflare init Create a new devflare project' }, + { argv: ['dev', '--help'], snippet: 'devflare dev Start the development server' }, + { argv: ['build', '--help'], snippet: 'devflare build Build production deployment artifacts' }, + { argv: ['deploy', '--help'], snippet: 'devflare deploy Deploy explicitly to Cloudflare production or preview targets' }, + { argv: ['types', '--help'], snippet: 'devflare types Generate TypeScript bindings from your config' }, + { argv: ['doctor', '--help'], snippet: 'devflare doctor Check project configuration' }, + { argv: ['config', '--help'], snippet: 'devflare config Print resolved Devflare or Wrangler config' }, + { argv: ['account', '--help'], snippet: 'devflare account Inspect Cloudflare accounts, resources, and usage data' }, + { argv: ['login', '--help'], snippet: 'devflare login Authenticate with Cloudflare via Wrangler' }, + { argv: ['previews', '--help'], snippet: 'devflare previews Inspect and clean dedicated preview Worker scopes' }, + { argv: ['productions', '--help'], snippet: 'devflare productions Inspect and manage live production Workers and deployments' }, + { argv: ['worker', '--help'], snippet: 'devflare worker Rename and manage Worker control-plane operations' }, + { argv: ['tokens', '--help'], snippet: 'devflare tokens Manage Devflare-managed Cloudflare API tokens' }, + { argv: ['secrets', '--help'], snippet: 'devflare secrets Manage local Secrets Store values' }, + { argv: ['ai', '--help'], snippet: 'devflare ai Show Workers AI pricing information' }, + { argv: ['remote', '--help'], snippet: 'devflare remote Manage remote test mode for paid Cloudflare features' }, + { argv: ['help', 'help'], snippet: 'devflare help Show command overview or command-specific help' }, + { argv: ['version', '--help'], snippet: 'devflare version Show the installed devflare version' } + ] + + for (const check of commandChecks) { + const result = await runCli(check.argv, { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toContain(check.snippet) + expect(result.output).toContain('usage') + } + }) + + test('provides detailed help pages for nested command paths', async () => { + const nestedChecks = [ + { argv: ['config', 'print', '--help'], snippet: 'devflare config print Print the resolved config' }, + { argv: ['account', 'info', '--help'], snippet: 'devflare account info Show the selected account overview' }, + { argv: ['account', 'workers', '--help'], snippet: 'devflare account workers List Workers in the selected account' }, + { argv: ['account', 'kv', '--help'], snippet: 'devflare account kv List KV namespaces in the selected account' }, + { argv: ['account', 'd1', '--help'], snippet: 'devflare account d1 List D1 databases in the selected account' }, + { argv: ['account', 'r2', '--help'], snippet: 'devflare account r2 List R2 buckets in the selected account' }, + { argv: ['account', 'vectorize', '--help'], snippet: 'devflare account vectorize List Vectorize indexes in the selected account' }, + { argv: ['account', 'usage', '--help'], snippet: 'devflare account usage Show Devflare usage summaries' }, + { argv: ['account', 'limits', '--help'], snippet: 'devflare account limits Show or update Devflare usage limits' }, + { argv: ['account', 'limits', 'set', '--help'], snippet: 'devflare account limits set Set one Devflare usage limit' }, + { argv: ['account', 'limits', 'enable', '--help'], snippet: 'devflare account limits enable Enable Devflare usage-limit enforcement' }, + { argv: ['account', 'limits', 'disable', '--help'], snippet: 'devflare account limits disable Disable Devflare usage-limit enforcement' }, + { argv: ['account', 'global', '--help'], snippet: 'devflare account global Choose the global default Cloudflare account' }, + { argv: ['account', 'workspace', '--help'], snippet: 'devflare account workspace Choose the workspace Cloudflare account' }, + { argv: ['previews', 'list', '--help'], snippet: 'devflare previews list List stable workers and dedicated preview scopes' }, + { argv: ['previews', 'bindings', '--help'], snippet: 'devflare previews bindings Inspect resolved bindings/resources and live worker associations' }, + { argv: ['previews', 'cleanup', '--help'], snippet: 'devflare previews cleanup Delete preview-only Worker scripts and preview-scoped Cloudflare resources' }, + { argv: ['productions', 'list', '--help'], snippet: 'devflare productions list List live production Workers and their active deployments' }, + { argv: ['productions', 'versions', '--help'], snippet: 'devflare productions versions Show recent stored production versions and the current active version' }, + { argv: ['productions', 'rollback', '--help'], snippet: 'devflare productions rollback Roll a Worker back to the previous or specified production version' }, + { argv: ['productions', 'delete', '--help'], snippet: 'devflare productions delete Delete a live production Worker script' }, + { argv: ['worker', 'rename', '--help'], snippet: 'devflare worker rename Rename a Worker and sync the matching config' }, + { argv: ['remote', 'status', '--help'], snippet: 'devflare remote status Show the current effective remote-mode status' }, + { argv: ['remote', 'enable', '--help'], snippet: 'devflare remote enable Enable remote test mode' }, + { argv: ['remote', 'disable', '--help'], snippet: 'devflare remote disable Disable remote test mode' } + ] + + for (const check of nestedChecks) { + const result = await runCli(check.argv, { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toContain(check.snippet) + expect(result.output).toContain('usage') + } + }) + + test('returns exit code 0 for version', async () => { + const result = await runCli(['--version'], { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toBe(await getPackageVersion()) + }) + + test('returns exit code 0 for direct version command', async () => { + const result = await runCli(['version'], { silent: true }) + expect(result.exitCode).toBe(0) + expect(result.output).toBe(await getPackageVersion()) + }) + + test('returns exit code 1 for unknown command', async () => { + const result = await runCli(['unknown'], { silent: true }) + expect(result.exitCode).toBe(1) + }) + + test('returns exit code 1 for unknown help topic', async () => { + const result = await runCli(['help', 'wat'], { silent: true }) + expect(result.exitCode).toBe(1) + }) +}) diff --git a/packages/devflare/tests/unit/cli/dependencies.test.ts b/packages/devflare/tests/unit/cli/dependencies.test.ts new file mode 100644 index 0000000..5c74cab --- /dev/null +++ b/packages/devflare/tests/unit/cli/dependencies.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { EventEmitter } from 'node:events' + +interface SpawnCall { + command: string + args: string[] + options: Record +} + +function installChildProcessMock(calls: SpawnCall[]): void { + const actual = require('node:child_process') + mock.module('node:child_process', () => ({ + ...actual, + spawn: (command: string, args: string[], options: Record) => { + calls.push({ command, args, options }) + const child = new EventEmitter() as EventEmitter & { + pid: number + stdout: null + stderr: null + killed: boolean + kill: (signal?: NodeJS.Signals) => boolean + } + child.pid = 1234 + child.stdout = null + child.stderr = null + child.killed = false + child.kill = () => true + return child + } + })) +} + +afterEach(() => { + mock.restore() +}) + +describe('createRealDependencies().exec.spawn', () => { + test('does not enable shell by default', async () => { + const calls: SpawnCall[] = [] + installChildProcessMock(calls) + + const { createRealDependencies } = await import('../../../src/cli/dependencies') + const deps = await createRealDependencies() + deps.exec.spawn('bun', ['--version']) + + expect(calls).toHaveLength(1) + expect(calls[0].command).toBe('bun') + expect(calls[0].args).toEqual(['--version']) + expect(calls[0].options.shell).toBe(false) + }) + + test('passes shell:true when caller explicitly opts in', async () => { + const calls: SpawnCall[] = [] + installChildProcessMock(calls) + + const { createRealDependencies } = await import('../../../src/cli/dependencies') + const deps = await createRealDependencies() + deps.exec.spawn('echo hi', [], { shell: true }) + + expect(calls).toHaveLength(1) + expect(calls[0].options.shell).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/dev-command.test.ts b/packages/devflare/tests/unit/cli/dev-command.test.ts new file mode 100644 index 0000000..af9e5b9 --- /dev/null +++ b/packages/devflare/tests/unit/cli/dev-command.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { resolveDevRuntimePort } from '../../../src/cli/commands/dev' + +const originalRuntimePort = process.env.DEVFLARE_RUNTIME_PORT +const originalBridgePort = process.env.DEVFLARE_BRIDGE_PORT + +afterEach(() => { + if (originalRuntimePort === undefined) { + delete process.env.DEVFLARE_RUNTIME_PORT + } else { + process.env.DEVFLARE_RUNTIME_PORT = originalRuntimePort + } + + if (originalBridgePort === undefined) { + delete process.env.DEVFLARE_BRIDGE_PORT + } else { + process.env.DEVFLARE_BRIDGE_PORT = originalBridgePort + } +}) + +describe('resolveDevRuntimePort', () => { + test('defaults to 8787', () => { + delete process.env.DEVFLARE_RUNTIME_PORT + delete process.env.DEVFLARE_BRIDGE_PORT + + expect(resolveDevRuntimePort({})).toBe(8787) + }) + + test('accepts --runtime-port and --bridge-port as aliases', () => { + expect(resolveDevRuntimePort({ 'runtime-port': '8791' })).toBe(8791) + expect(resolveDevRuntimePort({ 'bridge-port': '8792' })).toBe(8792) + }) + + test('uses DEVFLARE_RUNTIME_PORT and DEVFLARE_BRIDGE_PORT when no CLI option is set', () => { + process.env.DEVFLARE_RUNTIME_PORT = '8793' + expect(resolveDevRuntimePort({})).toBe(8793) + + delete process.env.DEVFLARE_RUNTIME_PORT + process.env.DEVFLARE_BRIDGE_PORT = '8794' + expect(resolveDevRuntimePort({})).toBe(8794) + }) + + test('rejects conflicting runtime and bridge port options', () => { + expect(() => resolveDevRuntimePort({ + 'runtime-port': '8795', + 'bridge-port': '8796' + })).toThrow('Conflicting Devflare runtime ports') + }) +}) diff --git a/packages/devflare/tests/unit/cli/login.test.ts b/packages/devflare/tests/unit/cli/login.test.ts new file mode 100644 index 0000000..2fe2c1a --- /dev/null +++ b/packages/devflare/tests/unit/cli/login.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { runLoginCommand } from '../../../src/cli/commands/login' +import { clearDependencies, setDependencies, type CliDependencies } from '../../../src/cli/dependencies' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger, stripAnsi } from '../../helpers/mock-logger' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN +const originalAccountId = process.env.CLOUDFLARE_ACCOUNT_ID + +function createAccountListResponse(): Response { + return jsonResponse([ + { + id: 'acc_123', + name: 'Devflare Account', + type: 'standard' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) +} + +function createExecDependencies( + execImplementation: NonNullable['exec'] +): CliDependencies { + return { + fs: {} as CliDependencies['fs'], + exec: { + exec: execImplementation, + spawn: () => { + throw new Error('spawn should not be called') + } + } + } +} + +function renderMessages(logger: ReturnType): string[] { + return logger.messages.map((message) => stripAnsi(message.args.join(' '))) +} + +function createRecordedExecDependencies(): { + execCalls: Array<{ command: string; args: string[] }> + deps: CliDependencies +} { + const execCalls: Array<{ command: string; args: string[] }> = [] + return { + execCalls, + deps: createExecDependencies(async (command, args = []) => { + execCalls.push({ command, args }) + return { + exitCode: 0, + stdout: '', + stderr: '', + failed: false, + killed: false + } + }) + } +} + +function mockAuthenticatedAccountFetch(): void { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch +} + +async function runLoginScenario( + logger: ReturnType, + options: { force?: boolean } = {} +) { + return await runLoginCommand( + { + command: 'login', + args: [], + options: options.force + ? { + force: true + } + : {} + }, + logger as any, + {} + ) +} + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } + if (originalAccountId === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = originalAccountId + } + clearDependencies() +}) + +describe('login command', () => { + test('skips Wrangler login when authentication already exists', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + mockAuthenticatedAccountFetch() + + const { execCalls, deps } = createRecordedExecDependencies() + setDependencies(deps) + + const logger = createLogger() + const result = await runLoginScenario(logger) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(execCalls).toHaveLength(0) + expect(renderedMessages.some((message) => message.includes('Already authenticated'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Devflare Account'))).toBe(true) + }) + + test('runs Wrangler login when forced', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + mockAuthenticatedAccountFetch() + + const { execCalls, deps } = createRecordedExecDependencies() + setDependencies(deps) + + const logger = createLogger() + const result = await runLoginScenario(logger, { force: true }) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(execCalls).toEqual([ + { + command: 'bunx', + args: ['wrangler', 'login'] + } + ]) + expect(renderedMessages.some((message) => message.includes('Authenticated with Cloudflare'))).toBe(true) + }) + + test('falls back to the configured account when account enumeration is unavailable', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + process.env.CLOUDFLARE_ACCOUNT_ID = 'acc_123' + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/accounts?page=1&per_page=50')) { + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 6003, message: 'Invalid request headers' }], + messages: [], + result: null + }), { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + }) + } + + if (url.endsWith('/accounts/acc_123')) { + return jsonResponse({ + id: 'acc_123', + name: 'Configured Account', + type: 'standard' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const deps = createExecDependencies(async () => ({ + exitCode: 0, + stdout: '', + stderr: '', + failed: false, + killed: false + })) + setDependencies(deps) + + const logger = createLogger() + const result = await runLoginCommand( + { + command: 'login', + args: [], + options: {} + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('Configured account: Configured Account (acc_123)'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/package-metadata.test.ts b/packages/devflare/tests/unit/cli/package-metadata.test.ts new file mode 100644 index 0000000..51f4fd8 --- /dev/null +++ b/packages/devflare/tests/unit/cli/package-metadata.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { getInitDependencyVersions } from '../../../src/cli/package-metadata' + +function readJsonFile(path: string): T { + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +function getMajorVersion(range: string): number { + const match = range.match(/\d+/) + if (!match) { + throw new Error(`Could not parse dependency range: ${range}`) + } + + return Number(match[0]) +} + +describe('package metadata', () => { + test('uses the package Wrangler dependency for new project scaffolds', async () => { + const packageJson = readJsonFile<{ dependencies?: Record }>( + join(import.meta.dir, '..', '..', '..', 'package.json') + ) + + const wrangler = packageJson.dependencies?.wrangler + if (!wrangler) { + throw new Error('Expected package.json to declare a Wrangler dependency') + } + expect(getMajorVersion(wrangler)).toBeGreaterThanOrEqual(4) + + const dependencyVersions = await getInitDependencyVersions() + expect(dependencyVersions.wrangler).toBe(wrangler) + }) + + test('uses the current Miniflare major for the local runtime dependency', () => { + const packageJson = readJsonFile<{ dependencies?: Record }>( + join(import.meta.dir, '..', '..', '..', 'package.json') + ) + + const miniflare = packageJson.dependencies?.miniflare + if (!miniflare) { + throw new Error('Expected package.json to declare a Miniflare dependency') + } + expect(getMajorVersion(miniflare)).toBeGreaterThanOrEqual(4) + }) + + test('aligns workers-types ranges across root and package manifests', () => { + const rootPackageJson = readJsonFile<{ devDependencies?: Record }>( + join(import.meta.dir, '..', '..', '..', '..', '..', 'package.json') + ) + const packageJson = readJsonFile<{ + devDependencies?: Record + peerDependencies?: Record + }>(join(import.meta.dir, '..', '..', '..', 'package.json')) + + const rootWorkersTypes = rootPackageJson.devDependencies?.['@cloudflare/workers-types'] + if (!rootWorkersTypes) { + throw new Error('Expected root package.json to declare @cloudflare/workers-types') + } + expect(packageJson.devDependencies?.['@cloudflare/workers-types']).toBe(rootWorkersTypes) + expect(packageJson.peerDependencies?.['@cloudflare/workers-types']).toBe(rootWorkersTypes) + }) + + test('documents the supported Cloudflare toolchain policy', () => { + const readme = readFileSync(join(import.meta.dir, '..', '..', '..', 'README.md'), 'utf8') + + expect(readme).toContain('## Cloudflare toolchain support') + expect(readme).toContain('Wrangler 4') + expect(readme).toContain('Miniflare 4') + expect(readme).toContain('@cloudflare/workers-types 4') + expect(readme).toContain('Devflare does not support Wrangler 3') + }) +}) diff --git a/packages/devflare/tests/unit/cli/preview-bindings.test.ts b/packages/devflare/tests/unit/cli/preview-bindings.test.ts new file mode 100644 index 0000000..5f04ccf --- /dev/null +++ b/packages/devflare/tests/unit/cli/preview-bindings.test.ts @@ -0,0 +1,425 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { + inspectBindingAssociations, + parseWranglerQueueInfo, + parseWranglerVersionBindings +} from '../../../src/cli/preview-bindings' +import { jsonResponse } from '../../helpers/cloudflare-api' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } +}) + +describe('preview binding inspection helpers', () => { + test('parses Wrangler versions view --json output into association rows', () => { + const parsed = parseWranglerVersionBindings(JSON.stringify({ + id: 'version-demo', + metadata: { author_email: 'demo@example.com', created_on: '2025-01-04T00:00:00.000Z' }, + resources: { + script: { handlers: ['fetch'] }, + script_runtime: { compatibility_date: '2025-01-01' }, + bindings: [ + { type: 'queue', name: 'JOBS', queue_name: 'jobs-queue' }, + { type: 'ratelimit', name: 'MY_RATE_LIMITER', namespace_id: '1001' }, + { type: 'service', name: 'AUTH_SERVICE', service: 'auth-service' }, + { type: 'worker_loader', name: 'LOADER' }, + { type: 'mtls_certificate', name: 'API_CERT', certificate_id: 'cert-123' }, + { type: 'dispatch_namespace', name: 'DISPATCHER', namespace: 'customers' }, + { type: 'workflow', name: 'ORDER_WORKFLOW', workflow_name: 'orders', class_name: 'OrderWorkflow' }, + { type: 'pipeline', name: 'EVENTS', pipeline: 'events-stream' }, + { type: 'images', name: 'IMAGES' }, + { type: 'media', name: 'MEDIA' }, + { type: 'artifacts', name: 'ARTIFACTS', namespace: 'default' }, + { type: 'secrets_store_secret', name: 'API_TOKEN', store_id: 'store-123', secret_name: 'api-token' }, + { type: 'analytics_engine', name: 'ANALYTICS', dataset: 'analytics-dataset' }, + { type: 'kv_namespace', name: 'CACHE', namespace_id: 'kv_abc' }, + { type: 'd1', name: 'DB', id: 'd1_xyz' }, + { type: 'r2_bucket', name: 'ASSETS', bucket_name: 'assets-bucket' }, + { type: 'durable_object_namespace', name: 'COUNTER', class_name: 'Counter', script_name: 'main' }, + { type: 'browser', name: 'BROWSER' }, + { type: 'ai', name: 'AI' }, + { type: 'plain_text', name: 'APP_NAME', text: 'demo-preview' }, + { type: 'secret_text', name: 'API_KEY' } + ] + } + })) + + expect(parsed).toEqual([ + { type: 'Queue', bindingName: 'JOBS', resource: 'jobs-queue' }, + { type: 'Rate Limiting', bindingName: 'MY_RATE_LIMITER', resource: '1001' }, + { type: 'Worker', bindingName: 'AUTH_SERVICE', resource: 'auth-service' }, + { type: 'Worker Loader', bindingName: 'LOADER', resource: 'Worker Loader' }, + { type: 'mTLS Certificate', bindingName: 'API_CERT', resource: 'cert-123' }, + { type: 'Dispatch Namespace', bindingName: 'DISPATCHER', resource: 'customers' }, + { type: 'Workflow', bindingName: 'ORDER_WORKFLOW', resource: 'orders' }, + { type: 'Pipeline', bindingName: 'EVENTS', resource: 'events-stream' }, + { type: 'Images', bindingName: 'IMAGES', resource: 'Images' }, + { type: 'Media Transformations', bindingName: 'MEDIA', resource: 'Media Transformations' }, + { type: 'Artifacts', bindingName: 'ARTIFACTS', resource: 'default' }, + { type: 'Secrets Store', bindingName: 'API_TOKEN', resource: 'store-123/api-token' }, + { type: 'Analytics Engine', bindingName: 'ANALYTICS', resource: 'analytics-dataset' }, + { type: 'KV Namespace', bindingName: 'CACHE', resource: 'kv_abc' }, + { type: 'D1 Database', bindingName: 'DB', resource: 'd1_xyz' }, + { type: 'R2 Bucket', bindingName: 'ASSETS', resource: 'assets-bucket' }, + { type: 'Durable Object Namespace', bindingName: 'COUNTER', resource: 'Counter' }, + { type: 'Browser', bindingName: 'BROWSER', resource: 'Browser Rendering' }, + { type: 'AI', bindingName: 'AI', resource: 'Workers AI' } + ]) + }) + + test('parseWranglerVersionBindings returns [] when JSON is malformed or missing bindings', () => { + expect(parseWranglerVersionBindings('not json')).toEqual([]) + expect(parseWranglerVersionBindings('{}')).toEqual([]) + expect(parseWranglerVersionBindings(JSON.stringify({ resources: {} }))).toEqual([]) + expect(parseWranglerVersionBindings(JSON.stringify({ resources: { bindings: [] } }))).toEqual([]) + }) + + test('parseWranglerVersionBindings carries entrypoint suffix on service bindings', () => { + const parsed = parseWranglerVersionBindings(JSON.stringify({ + resources: { + bindings: [ + { type: 'service', name: 'INTERNAL', service: 'core-worker', entrypoint: 'AdminAPI' } + ] + } + })) + + expect(parsed).toEqual([ + { type: 'Worker', bindingName: 'INTERNAL', resource: 'core-worker#AdminAPI' } + ]) + }) + + test('parses Wrangler queue info output with inline and multiline worker lists', () => { + const parsed = parseWranglerQueueInfo(` +Queue Name: jobs-queue +Producers: + worker:demo-worker + worker:other-worker +Consumers: worker:demo-worker +`.trim()) + + expect(parsed).toEqual({ + queueName: 'jobs-queue', + producerWorkers: ['demo-worker', 'other-worker'], + consumerWorkers: ['demo-worker'] + }) + }) + + test('aggregates binding associations across deployed workers and queue attachments', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'script_demo', + name: 'demo-worker', + created_on: '2025-01-01T00:00:00.000Z', + modified_on: '2025-01-02T00:00:00.000Z' + }, + { + id: 'script_other', + name: 'other-worker', + created_on: '2025-01-01T00:00:00.000Z', + modified_on: '2025-01-02T00:00:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 2, + total_count: 2 + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deploy_demo', + created_on: '2025-01-04T00:00:00.000Z', + source: 'api', + strategy: 'percentage', + versions: [ + { percentage: 100, version_id: 'version-demo' } + ], + annotations: {}, + author_email: 'demo@example.com' + } + ] + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/other-worker/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deploy_other', + created_on: '2025-01-04T00:00:00.000Z', + source: 'api', + strategy: 'percentage', + versions: [ + { percentage: 100, version_id: 'version-other' } + ], + annotations: {}, + author_email: 'other@example.com' + } + ] + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const execCalls: Array<{ command: string; args: string[] }> = [] + const exec = { + exec: async (command: string, args: string[] = []) => { + execCalls.push({ command, args }) + const joined = `${command} ${args.join(' ')}` + + if (joined === 'bunx wrangler versions view version-demo --name demo-worker --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + resources: { + bindings: [ + { type: 'queue', name: 'JOBS', queue_name: 'jobs-queue' }, + { type: 'ratelimit', name: 'MY_RATE_LIMITER', namespace_id: '1001' }, + { type: 'version_metadata', name: 'CF_VERSION_METADATA' }, + { type: 'worker_loader', name: 'LOADER' }, + { type: 'mtls_certificate', name: 'API_CERT', certificate_id: 'cert-123' }, + { type: 'dispatch_namespace', name: 'DISPATCHER', namespace: 'customers' }, + { type: 'workflow', name: 'ORDER_WORKFLOW', workflow_name: 'orders', class_name: 'OrderWorkflow' }, + { type: 'pipeline', name: 'EVENTS', pipeline: 'events-stream' }, + { type: 'images', name: 'IMAGES' }, + { type: 'media', name: 'MEDIA' }, + { type: 'artifacts', name: 'ARTIFACTS', namespace: 'default' }, + { type: 'secrets_store_secret', name: 'API_TOKEN', store_id: 'store-123', secret_name: 'api-token' }, + { type: 'service', name: 'AUTH_SERVICE', service: 'auth-service' } + ] + } + }), + stderr: '', + failed: false, + killed: false + } + } + + if (joined === 'bunx wrangler versions view version-other --name other-worker --json') { + return { + exitCode: 0, + stdout: JSON.stringify({ + resources: { + bindings: [ + { type: 'queue', name: 'JOBS', queue_name: 'jobs-queue' } + ] + } + }), + stderr: '', + failed: false, + killed: false + } + } + + if (joined === 'bunx wrangler queues info jobs-queue') { + return { + exitCode: 0, + stdout: ` +Queue Name: jobs-queue +Producers: worker:demo-worker, worker:other-worker +Consumers: worker:demo-worker +`.trim(), + stderr: '', + failed: false, + killed: false + } + } + + if (joined === 'bunx wrangler queues info jobs-dlq') { + return { + exitCode: 0, + stdout: ` +Queue Name: jobs-dlq +Producers: +Consumers: +`.trim(), + stderr: '', + failed: false, + killed: false + } + } + + throw new Error(`Unexpected exec call: ${joined}`) + }, + spawn: () => { + throw new Error('spawn should not be called') + } + } + + const inspection = await inspectBindingAssociations({ + accountId: 'acc_123', + config: { + name: 'demo-worker', + accountId: 'acc_123', + compatibilityDate: '2025-01-01', + compatibilityFlags: [], + bindings: { + queues: { + producers: { JOBS: 'jobs-queue' }, + consumers: [ + { queue: 'jobs-queue', deadLetterQueue: 'jobs-dlq' } + ] + }, + services: { + AUTH_SERVICE: { service: 'auth-service' } + }, + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { limit: 100, period: 60 } + } + }, + versionMetadata: { binding: 'CF_VERSION_METADATA' }, + workerLoaders: { + LOADER: {} + }, + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123' + } + }, + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers' + } + }, + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow' + } + }, + pipelines: { + EVENTS: { + pipeline: 'events-stream' + } + }, + images: { + IMAGES: {} + }, + media: { + MEDIA: {} + }, + artifacts: { + ARTIFACTS: 'default' + }, + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + } + }, + tailConsumers: [ + 'observability-tail' + ] + }, + workerName: 'demo-worker', + cwd: process.cwd(), + exec + }) + + const jobsRow = inspection.rows.find((row) => row.resource === 'jobs-queue') + const rateLimitRow = inspection.rows.find((row) => row.resource === '1001') + const versionMetadataRow = inspection.rows.find((row) => row.reference === 'CF_VERSION_METADATA') + const workerLoaderRow = inspection.rows.find((row) => row.reference === 'LOADER') + const mtlsCertificateRow = inspection.rows.find((row) => row.reference === 'API_CERT') + const dispatchNamespaceRow = inspection.rows.find((row) => row.reference === 'DISPATCHER') + const workflowRow = inspection.rows.find((row) => row.reference === 'ORDER_WORKFLOW') + const pipelineRow = inspection.rows.find((row) => row.reference === 'EVENTS') + const imagesRow = inspection.rows.find((row) => row.reference === 'IMAGES') + const mediaRow = inspection.rows.find((row) => row.reference === 'MEDIA') + const artifactsRow = inspection.rows.find((row) => row.reference === 'ARTIFACTS') + const secretsStoreRow = inspection.rows.find((row) => row.reference === 'API_TOKEN') + const tailConsumerRow = inspection.rows.find((row) => row.resource === 'observability-tail') + const authRow = inspection.rows.find((row) => row.resource === 'auth-service') + const dlqRow = inspection.rows.find((row) => row.resource === 'jobs-dlq') + + expect(inspection.workerName).toBe('demo-worker') + expect(inspection.scannedWorkers).toEqual(['demo-worker', 'other-worker']) + expect(jobsRow).toBeDefined() + expect(jobsRow?.reference).toBe('JOBS') + expect(jobsRow?.workerCount).toBe(2) + expect(jobsRow?.connectedWorkers).toEqual(['demo-worker', 'other-worker']) + expect(jobsRow?.notes).toContain('producer binding') + expect(jobsRow?.notes).toContain('consumer attachment') + expect(jobsRow?.notes).toContain('producers 2') + expect(jobsRow?.notes).toContain('consumers 1') + expect(rateLimitRow).toBeDefined() + expect(rateLimitRow?.reference).toBe('MY_RATE_LIMITER') + expect(rateLimitRow?.type).toBe('Rate Limiting') + expect(rateLimitRow?.workerCount).toBe(1) + expect(versionMetadataRow).toBeDefined() + expect(versionMetadataRow?.type).toBe('Version Metadata') + expect(versionMetadataRow?.workerCount).toBe(1) + expect(workerLoaderRow).toBeDefined() + expect(workerLoaderRow?.type).toBe('Worker Loader') + expect(workerLoaderRow?.workerCount).toBe(1) + expect(mtlsCertificateRow).toBeDefined() + expect(mtlsCertificateRow?.type).toBe('mTLS Certificate') + expect(mtlsCertificateRow?.resource).toBe('cert-123') + expect(mtlsCertificateRow?.workerCount).toBe(1) + expect(dispatchNamespaceRow).toBeDefined() + expect(dispatchNamespaceRow?.type).toBe('Dispatch Namespace') + expect(dispatchNamespaceRow?.resource).toBe('customers') + expect(dispatchNamespaceRow?.workerCount).toBe(1) + expect(workflowRow).toBeDefined() + expect(workflowRow?.type).toBe('Workflow') + expect(workflowRow?.resource).toBe('orders') + expect(workflowRow?.workerCount).toBe(1) + expect(pipelineRow).toBeDefined() + expect(pipelineRow?.type).toBe('Pipeline') + expect(pipelineRow?.resource).toBe('events-stream') + expect(pipelineRow?.workerCount).toBe(1) + expect(imagesRow).toBeDefined() + expect(imagesRow?.type).toBe('Images') + expect(imagesRow?.resource).toBe('Images') + expect(imagesRow?.workerCount).toBe(1) + expect(mediaRow).toBeDefined() + expect(mediaRow?.type).toBe('Media Transformations') + expect(mediaRow?.resource).toBe('Media Transformations') + expect(mediaRow?.workerCount).toBe(1) + expect(artifactsRow).toBeDefined() + expect(artifactsRow?.type).toBe('Artifacts') + expect(artifactsRow?.resource).toBe('default') + expect(artifactsRow?.workerCount).toBe(1) + expect(secretsStoreRow).toBeDefined() + expect(secretsStoreRow?.type).toBe('Secrets Store') + expect(secretsStoreRow?.resource).toBe('store-123/api-token') + expect(secretsStoreRow?.workerCount).toBe(1) + expect(tailConsumerRow).toBeDefined() + expect(tailConsumerRow?.type).toBe('Tail Consumer') + expect(tailConsumerRow?.workerCount).toBe(0) + expect(authRow).toBeDefined() + expect(authRow?.reference).toBe('AUTH_SERVICE') + expect(authRow?.workerCount).toBe(1) + expect(authRow?.connectedWorkers).toEqual(['demo-worker']) + expect(dlqRow).toBeDefined() + expect(dlqRow?.workerCount).toBe(0) + expect(dlqRow?.notes).toContain('dead letter queue') + expect(execCalls.map((call) => `${call.command} ${call.args.join(' ')}`)).toEqual([ + 'bunx wrangler versions view version-demo --name demo-worker --json', + 'bunx wrangler versions view version-other --name other-worker --json', + 'bunx wrangler queues info jobs-queue', + 'bunx wrangler queues info jobs-dlq' + ]) + }) +}) diff --git a/packages/devflare/tests/unit/cli/preview.test.ts b/packages/devflare/tests/unit/cli/preview.test.ts new file mode 100644 index 0000000..f136a9f --- /dev/null +++ b/packages/devflare/tests/unit/cli/preview.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { + formatWorkersDevUrl, + formatVersionPreviewUrl, + mergeParsedWranglerDeployOutputs, + parseWranglerDeployOutput, + parseWranglerStructuredOutput +} from '../../../src/cli/preview' + +describe('preview helpers', () => { + test('formats workers.dev urls using the account subdomain', () => { + expect(formatWorkersDevUrl('demo-worker', 'example-subdomain')).toBe( + 'https://demo-worker.example-subdomain.workers.dev' + ) + expect(formatWorkersDevUrl('demo-worker', 'example-subdomain.workers.dev')).toBe( + 'https://demo-worker.example-subdomain.workers.dev' + ) + }) + + test('formats version preview urls using the version prefix and workers.dev subdomain', () => { + expect(formatVersionPreviewUrl('5dba9570-33c4-4375-b784-e1b34ad01569', 'demo-worker', 'example-subdomain')).toBe( + 'https://5dba9570-demo-worker.example-subdomain.workers.dev' + ) + }) + + test('parses version ids and preview urls from wrangler output', () => { + const parsed = parseWranglerDeployOutput(` +Worker Version ID: version-123 +Version Preview URL: https://preview.example.workers.dev +`.trim()) + + expect(parsed.versionId).toBe('version-123') + expect(parsed.previewUrl).toBe('https://preview.example.workers.dev') + expect(parsed.urls).toEqual(['https://preview.example.workers.dev']) + }) + + test('parses version ids and targets from Wrangler structured output', () => { + const parsed = parseWranglerStructuredOutput([ + JSON.stringify({ + type: 'wrangler-session', + version: 1 + }), + JSON.stringify({ + type: 'deploy', + version: 1, + worker_name: 'demo-worker', + version_id: 'version-123', + targets: ['https://demo-worker.example.workers.dev'] + }) + ].join('\n')) + + expect(parsed.versionId).toBe('version-123') + expect(parsed.previewUrl).toBe('https://demo-worker.example.workers.dev') + expect(parsed.urls).toEqual(['https://demo-worker.example.workers.dev']) + }) + + test('prefers the explicit structured preview url when it is present', () => { + const parsed = parseWranglerStructuredOutput(JSON.stringify({ + type: 'deploy', + version_id: 'version-123', + preview_url: 'https://preview.example.workers.dev', + targets: ['https://demo-worker.example.workers.dev'] + })) + + expect(parsed.versionId).toBe('version-123') + expect(parsed.previewUrl).toBe('https://preview.example.workers.dev') + expect(parsed.urls).toEqual(['https://demo-worker.example.workers.dev']) + }) + + test('prefers Wrangler structured output when console output omits the version id', () => { + const parsed = mergeParsedWranglerDeployOutputs( + parseWranglerDeployOutput('Deployed successfully to https://demo-worker.example.workers.dev'), + parseWranglerStructuredOutput(JSON.stringify({ + type: 'deploy', + version_id: 'version-123', + targets: ['https://demo-worker.example.workers.dev'] + })) + ) + + expect(parsed.versionId).toBe('version-123') + expect(parsed.previewUrl).toBe('https://demo-worker.example.workers.dev') + }) +}) diff --git a/packages/devflare/tests/unit/cli/previews-cleanup-resources.test.ts b/packages/devflare/tests/unit/cli/previews-cleanup-resources.test.ts new file mode 100644 index 0000000..e2bfc0b --- /dev/null +++ b/packages/devflare/tests/unit/cli/previews-cleanup-resources.test.ts @@ -0,0 +1,355 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { runPreviewsCommand } from '../../../src/cli/commands/previews' +import { createTrackedTempDirectories } from '../../helpers/tracked-temp-directories' +import { createLogger, jsonResponse, renderMessages } from './previews.test-utils' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN +const originalCacheDir = process.env.DEVFLARE_CACHE_DIR +const temporaryCacheDirectories = createTrackedTempDirectories() + +function writeKvCleanupProject(projectDir: string, projectName: string): void { + const previewScopedValue = `__DEVFLARE_PREVIEW_SCOPE__:${JSON.stringify({ baseName: 'cache-kv', separator: '-' })}` + writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ + name: projectName, + type: 'module' + }, null, '\t'), 'utf-8') + writeFileSync(join(projectDir, 'devflare.config.ts'), ` + export default { + name: ${JSON.stringify(projectName)}, + accountId: 'acc_123', + compatibilityDate: '2026-04-08', + bindings: { + kv: { + CACHE: ${JSON.stringify(previewScopedValue)} + } + } + } + `, 'utf-8') +} + +function writeServiceCleanupProject(projectDir: string): void { + writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ + name: 'demo-preview-cleanup-apply-order', + type: 'module' + }, null, '\t'), 'utf-8') + writeFileSync(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'demo-worker', + accountId: 'acc_123', + compatibilityDate: '2026-04-08', + bindings: { + services: { + AUTH_SERVICE: { service: 'demo-auth-service' } + } + } + } + `, 'utf-8') +} + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } + if (originalCacheDir === undefined) { + delete process.env.DEVFLARE_CACHE_DIR + } else { + process.env.DEVFLARE_CACHE_DIR = originalCacheDir + } + temporaryCacheDirectories.cleanup() +}) + +describe('previews command', () => { + test('cleanup warns when it falls back to the default preview scope and finds no matching resources', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = temporaryCacheDirectories.create('devflare-previews-cleanup-default-') + writeKvCleanupProject(projectDir, 'demo-preview-cleanup') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + throw new Error('previews cleanup should not query preview-registry D1 state') + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + if (url.includes('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['cleanup'], + options: { + account: 'acc_123' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('preview scope preview (default preview scope)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('No preview-only resources or dedicated preview Worker scripts matched the default "preview" scope'))).toBe(true) + }) + + test('cleanup uses --scope to target named preview resources', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = temporaryCacheDirectories.create('devflare-previews-cleanup-scope-') + writeKvCleanupProject(projectDir, 'demo-preview-cleanup-scope') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + throw new Error('previews cleanup should not query preview-registry D1 state') + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'demo-preview-cleanup-scope-next', + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) + } + + if (url.includes('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'kv-next', + title: 'cache-kv-next' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['cleanup'], + options: { + account: 'acc_123', + scope: 'next' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('preview scope next (--scope)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Preview cleanup dry run complete with 2 candidates across 1 preview scope'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Candidates: Workers 1 ยท KV 1'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('scope breakdown'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('next') && message.includes('dedicated workers') && message.includes('Workers 1'))).toBe(true) + }) + + test('cleanup uses --all to clean every live discovered preview scope', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = temporaryCacheDirectories.create('devflare-previews-cleanup-all-') + writeKvCleanupProject(projectDir, 'demo-preview-cleanup-all') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + throw new Error('previews cleanup should not query preview-registry D1 state') + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'demo-preview-cleanup-all-next', + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + }, + { + id: 'demo-preview-cleanup-all-pr-1', + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 2, + total_count: 2 + }) + } + + if (url.includes('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'kv-next', + title: 'cache-kv-next' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['cleanup'], + options: { + account: 'acc_123', + all: true + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('preview scopes next, pr-1 (--all)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Preview cleanup dry run complete with 3 candidates across 2 preview scopes'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Candidates: Workers 2 ยท KV 1'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('scope breakdown'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('next') && message.includes('dedicated workers') && message.includes('Workers 1'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('pr-1') && message.includes('dedicated workers') && message.includes('Workers 1'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('preview') && message.includes('default preview scope'))).toBe(false) + }) + + test('cleanup deletes preview worker consumers before preview service providers', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = temporaryCacheDirectories.create('devflare-previews-cleanup-apply-order-') + writeServiceCleanupProject(projectDir) + + const deletedWorkers: string[] = [] + let mainWorkerDeleted = false + + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + throw new Error('previews cleanup should not query preview-registry D1 state') + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'demo-auth-service-next', + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + }, + { + id: 'demo-worker-next', + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 2, + total_count: 2 + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker-next') && init?.method === 'DELETE') { + mainWorkerDeleted = true + deletedWorkers.push('demo-worker-next') + return jsonResponse({}) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-auth-service-next') && init?.method === 'DELETE') { + if (!mainWorkerDeleted) { + return new Response(JSON.stringify({ + success: false, + errors: [ + { + message: "Cannot delete service 'demo-auth-service-next' because it is still referenced by service bindings in Workers 'demo-worker-next'. Please remove bindings pointing to it and try again." + } + ], + messages: [], + result: null + }), { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + }) + } + + deletedWorkers.push('demo-auth-service-next') + return jsonResponse({}) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['cleanup'], + options: { + account: 'acc_123', + scope: 'next', + apply: true + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(deletedWorkers).toEqual(['demo-worker-next', 'demo-auth-service-next']) + expect(renderedMessages.some((message) => message.includes('Deleted 2 preview-only cleanup candidates across 1 preview scope'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/previews-family-summary.test.ts b/packages/devflare/tests/unit/cli/previews-family-summary.test.ts new file mode 100644 index 0000000..ef15f3d --- /dev/null +++ b/packages/devflare/tests/unit/cli/previews-family-summary.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtempSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createTrackedTempDirectories } from '../../helpers/tracked-temp-directories' +import { + capturePreviewTestEnvironmentSnapshot, + jsonResponse, + runTrackedPreviewsCommand, + restorePreviewTestEnvironmentSnapshot +} from './previews.test-utils' + +const originalEnvironment = capturePreviewTestEnvironmentSnapshot() +const temporaryCacheDirectories = createTrackedTempDirectories() + +function writeFamilyProject(projectDir: string, cacheDir: string, packageName: string): string { + const configPath = join(projectDir, 'devflare.config.ts') + writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ + name: packageName, + type: 'module' + }, null, '\t'), 'utf-8') + writeFileSync(configPath, ` + export default { + name: 'demo-worker', + accountId: 'acc_123', + compatibilityDate: '2025-01-01', + bindings: { + services: { + AUTH_SERVICE: { service: 'demo-auth-service' }, + SEARCH_SERVICE: { service: 'demo-search-service' } + } + } + } + `, 'utf-8') + writeFileSync(join(cacheDir, 'preview-command-config.json'), JSON.stringify({ + configs: { + [configPath]: { + accountId: 'acc_123', + name: 'demo-worker', + mtimeMs: statSync(configPath).mtimeMs + } + } + }), 'utf-8') + + return configPath +} + +function expectWorkerFamilyHeading(renderedMessages: string[]): void { + expect(renderedMessages.some((message) => message.includes('worker family demo-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('related workers 2'))).toBe(true) +} + +afterEach(() => { + restorePreviewTestEnvironmentSnapshot(originalEnvironment) + temporaryCacheDirectories.cleanup() +}) + +describe('previews command', () => { + test('summarizes preview scopes across related worker families from live workers', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const cacheDir = temporaryCacheDirectories.create('devflare-previews-cli-') + process.env.DEVFLARE_CACHE_DIR = cacheDir + const projectDir = mkdtempSync(join(tmpdir(), 'devflare-previews-family-')) + temporaryCacheDirectories.track(projectDir) + writeFamilyProject(projectDir, cacheDir, 'demo-worker-family') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.endsWith('/accounts/acc_123/workers/subdomain')) { + return jsonResponse({ subdomain: 'example-subdomain' }) + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { id: 'demo-worker', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' }, + { id: 'demo-auth-service', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' }, + { id: 'demo-search-service', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' }, + { id: 'demo-worker-next', created_on: '2025-01-03T00:00:00.000Z', modified_on: '2025-01-03T01:00:00.000Z' }, + { id: 'demo-auth-service-next', created_on: '2025-01-03T00:00:00.000Z', modified_on: '2025-01-03T01:00:00.000Z' }, + { id: 'demo-search-service-next', created_on: '2025-01-03T00:00:00.000Z', modified_on: '2025-01-03T01:00:00.000Z' }, + { id: 'demo-auth-service-pr-1', created_on: '2025-01-04T00:00:00.000Z', modified_on: '2025-01-04T01:00:00.000Z' }, + { id: 'demo-search-service-pr-1', created_on: '2025-01-04T00:00:00.000Z', modified_on: '2025-01-04T01:00:00.000Z' } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 8, + total_count: 8 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const { result, renderedMessages } = await runTrackedPreviewsCommand({ cwd: projectDir }) + + expect(result.exitCode).toBe(0) + expectWorkerFamilyHeading(renderedMessages) + expect(renderedMessages.some((message) => message.includes('Stable workers (3)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Preview scopes (2)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('dedicated workers'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('next'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('pr-1'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('3/3'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('2/3'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('missing primary'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker-next.example-subdomain.workers.dev'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Preview scopes are derived from live dedicated preview Worker names'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('โ”Œ worker demo-worker-next'))).toBe(false) + }) + + test('previews list stays registry-free and reports when no dedicated preview scopes exist yet', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const cacheDir = temporaryCacheDirectories.create('devflare-previews-cli-') + process.env.DEVFLARE_CACHE_DIR = cacheDir + const projectDir = mkdtempSync(join(tmpdir(), 'devflare-previews-live-only-')) + temporaryCacheDirectories.track(projectDir) + writeFamilyProject(projectDir, cacheDir, 'demo-worker-family-live-only') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + throw new Error('previews list should no longer query the preview registry') + } + + if (url.endsWith('/accounts/acc_123/workers/subdomain')) { + return jsonResponse({ subdomain: 'example-subdomain' }) + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { id: 'demo-worker', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' }, + { id: 'demo-auth-service', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' }, + { id: 'demo-search-service', created_on: '2025-01-01T10:00:00.000Z', modified_on: '2025-01-01T10:00:00.000Z' } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 3, + total_count: 3 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const { result, renderedMessages } = await runTrackedPreviewsCommand({ cwd: projectDir }) + + expect(result.exitCode).toBe(0) + expectWorkerFamilyHeading(renderedMessages) + expect(renderedMessages.some((message) => message.includes('No dedicated preview scopes found for this worker family.'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('preview registry'))).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/cli/previews.test-utils.ts b/packages/devflare/tests/unit/cli/previews.test-utils.ts new file mode 100644 index 0000000..c9a64f1 --- /dev/null +++ b/packages/devflare/tests/unit/cli/previews.test-utils.ts @@ -0,0 +1,266 @@ +import { runPreviewsCommand } from '../../../src/cli/commands/previews' +import { createD1ResultsResponse, jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger, renderMessages, stripAnsi, type TestLogger } from '../../helpers/mock-logger' + +export type { TestLogger } +export { createLogger, renderMessages, stripAnsi } +export { createD1ResultsResponse, jsonResponse } + +export interface PreviewTestEnvironmentSnapshot { + fetch: typeof fetch + token?: string + cacheDir?: string +} + +export function capturePreviewTestEnvironmentSnapshot(): PreviewTestEnvironmentSnapshot { + return { + fetch: globalThis.fetch, + token: process.env.CLOUDFLARE_API_TOKEN, + cacheDir: process.env.DEVFLARE_CACHE_DIR + } +} + +function restoreOptionalEnvironmentVariable(name: string, value: string | undefined): void { + if (typeof value === 'undefined') { + delete process.env[name] + return + } + + process.env[name] = value +} + +export function restorePreviewTestEnvironmentSnapshot(snapshot: PreviewTestEnvironmentSnapshot): void { + globalThis.fetch = snapshot.fetch + restoreOptionalEnvironmentVariable('CLOUDFLARE_API_TOKEN', snapshot.token) + restoreOptionalEnvironmentVariable('DEVFLARE_CACHE_DIR', snapshot.cacheDir) +} + +export function createRegistryDatabaseListResponse(databases: Array>): Response { + return jsonResponse(databases, { + page: 1, + per_page: 50, + total_pages: 1, + count: databases.length, + total_count: databases.length + }) +} + +export function createRegistryDatabaseRecord(options: { + uuid?: string + name?: string + version?: string + numTables?: number + fileSize?: number +} = {}): Record { + return { + uuid: options.uuid ?? 'db_123', + name: options.name ?? 'devflare-registry', + version: options.version ?? 'alpha', + num_tables: options.numTables ?? 3, + file_size: options.fileSize ?? 1024 + } +} + +export function createSerializedRegistryRecord(record: Record): { + payload_json: string +} { + return { + payload_json: JSON.stringify(record) + } +} + +interface PreviewRegistryFetchOptions { + databases?: Array> + previewRecords?: Array> + deploymentRecords?: Array> + onRequest?: (url: string, init?: RequestInit) => Response | Promise | undefined + onQuery?: (sql: string, url: string, init?: RequestInit) => Response | Promise | undefined +} + +export function createPreviewRegistryFetch( + options: PreviewRegistryFetchOptions = {} +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + const databases = options.databases ?? [createRegistryDatabaseRecord()] + const previewRecords = options.previewRecords ?? [] + const deploymentRecords = options.deploymentRecords ?? [] + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input) + const requestResponse = await options.onRequest?.(url, init) + if (requestResponse) { + return requestResponse + } + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + return createRegistryDatabaseListResponse(databases) + } + + if (url.endsWith('/accounts/acc_123/d1/database/db_123/query')) { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : {} + const sql = String(body.sql ?? '') + const queryResponse = await options.onQuery?.(sql, url, init) + if (queryResponse) { + return queryResponse + } + + if (sql.startsWith('SELECT payload_json FROM devflare_preview_records')) { + return createD1ResultsResponse(previewRecords.map(createSerializedRegistryRecord)) + } + + if (sql.startsWith('SELECT payload_json FROM devflare_deployment_records')) { + return createD1ResultsResponse(deploymentRecords.map(createSerializedRegistryRecord)) + } + + return createD1ResultsResponse() + } + + throw new Error(`Unexpected fetch URL: ${url}`) + } +} + +export async function runTrackedPreviewsCommand(options: { + args?: string[] + account?: string + cwd?: string +} = {}): Promise<{ + logger: TestLogger + result: Awaited> + renderedMessages: string[] +}> { + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: options.args ?? [], + options: { + account: options.account ?? 'acc_123' + } + }, + logger as any, + options.cwd ? { cwd: options.cwd } : {} + ) + + return { + logger, + result, + renderedMessages: renderMessages(logger) + } +} + +interface PreviewRecordFixtureOptions { + accountId?: string + workerName: string + versionId: string + previewUrl?: string + scope?: string + scopeUrl?: string + branchName?: string + source?: string + status?: string + createdAt?: string + updatedAt?: string + createdBy?: string + id?: string +} + +export function createPreviewRecordFixture( + options: PreviewRecordFixtureOptions +): Record { + const id = options.id ?? `preview:${options.workerName}:${options.versionId}` + return { + id, + kind: 'preview', + ver: 1, + createdAt: options.createdAt ?? '2025-01-01T00:00:00.000Z', + updatedAt: options.updatedAt ?? '2025-01-02T00:00:00.000Z', + createdBy: options.createdBy ?? 'user_123', + accountId: options.accountId ?? 'acc_123', + workerName: options.workerName, + versionId: options.versionId, + ...(options.previewUrl ? { previewUrl: options.previewUrl } : {}), + ...(options.scope ? { scope: options.scope } : {}), + ...(options.scopeUrl ? { scopeUrl: options.scopeUrl } : {}), + ...(options.branchName ? { branchName: options.branchName } : {}), + source: options.source ?? 'cli', + status: options.status ?? 'active' + } +} + +interface PreviewScopeFixtureOptions { + accountId?: string + workerName: string + scope: string + versionId: string + previewId?: string + scopeUrl?: string + branchName?: string + source?: string + status?: string + createdAt?: string + updatedAt?: string + createdBy?: string + id?: string +} + +export function createPreviewScopeRecordFixture( + options: PreviewScopeFixtureOptions +): Record { + return { + id: options.id ?? `previewScope:${options.workerName}:${options.scope}`, + kind: 'previewScope', + ver: 1, + createdAt: options.createdAt ?? '2025-01-01T00:00:00.000Z', + updatedAt: options.updatedAt ?? '2025-01-02T00:00:00.000Z', + createdBy: options.createdBy ?? 'user_123', + accountId: options.accountId ?? 'acc_123', + workerName: options.workerName, + scope: options.scope, + ...(options.scopeUrl ? { scopeUrl: options.scopeUrl } : {}), + versionId: options.versionId, + previewId: options.previewId ?? `preview:${options.workerName}:${options.versionId}`, + ...(options.branchName ? { branchName: options.branchName } : {}), + source: options.source ?? 'cli', + status: options.status ?? 'active' + } +} + +interface DeploymentRecordFixtureOptions { + accountId?: string + workerName: string + deploymentId: string + channel: string + versionId: string + previewId?: string + environment?: string + url?: string + source?: string + status?: string + createdAt?: string + updatedAt?: string + createdBy?: string + id?: string +} + +export function createDeploymentRecordFixture( + options: DeploymentRecordFixtureOptions +): Record { + const id = options.id ?? `deployment:${options.workerName}:${options.deploymentId}` + return { + id, + kind: 'deployment', + ver: 1, + createdAt: options.createdAt ?? '2025-01-01T00:00:00.000Z', + updatedAt: options.updatedAt ?? '2025-01-02T00:00:00.000Z', + createdBy: options.createdBy ?? 'user_123', + accountId: options.accountId ?? 'acc_123', + workerName: options.workerName, + deploymentId: options.deploymentId, + channel: options.channel, + status: options.status ?? 'active', + versionId: options.versionId, + ...(options.previewId ? { previewId: options.previewId } : {}), + ...(options.environment ? { environment: options.environment } : {}), + ...(options.url ? { url: options.url } : {}), + source: options.source ?? 'cli' + } +} diff --git a/packages/devflare/tests/unit/cli/previews.test.ts b/packages/devflare/tests/unit/cli/previews.test.ts new file mode 100644 index 0000000..161d6ef --- /dev/null +++ b/packages/devflare/tests/unit/cli/previews.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { runPreviewsCommand } from '../../../src/cli/commands/previews' +import { createTrackedTempDirectories } from '../../helpers/tracked-temp-directories' +import { + capturePreviewTestEnvironmentSnapshot, + createLogger, + jsonResponse, + renderMessages, + restorePreviewTestEnvironmentSnapshot +} from './previews.test-utils' + +const originalEnvironment = capturePreviewTestEnvironmentSnapshot() +const temporaryCacheDirectories = createTrackedTempDirectories() + +function writePreviewProject(projectDir: string, projectName: string, accountId: string = 'acc_123'): void { + const previewScopedValue = `__DEVFLARE_PREVIEW_SCOPE__:${JSON.stringify({ baseName: 'cache-kv', separator: '-' })}` + writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ + name: projectName, + type: 'module' + }, null, '\t'), 'utf-8') + writeFileSync(join(projectDir, 'devflare.config.ts'), ` + export default { + name: ${JSON.stringify(projectName)}, + accountId: ${JSON.stringify(accountId)}, + compatibilityDate: '2026-04-08', + bindings: { + kv: { + CACHE: ${JSON.stringify(previewScopedValue)} + } + } + } + `, 'utf-8') +} + +afterEach(() => { + restorePreviewTestEnvironmentSnapshot(originalEnvironment) + temporaryCacheDirectories.cleanup() +}) + +describe('previews command', () => { + test('rejects unknown subcommands', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['demo-worker'], + options: { + account: 'acc_123' + } + }, + logger as any, + {} + ) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.args.join(' ').includes('Unknown previews subcommand: demo-worker'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('Available previews subcommands: list, bindings, cleanup'))).toBe(true) + }) + + test('cleanup performs a dry run by default', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = temporaryCacheDirectories.create('devflare-previews-cleanup-') + writePreviewProject(projectDir, 'demo-preview-cleanup') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + if (url.includes('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: ['cleanup'], + options: { + account: 'acc_123' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('Preview cleanup dry run complete'))).toBe(true) + }) + + test('lists every configured worker family when run from a monorepo root', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const workspaceDir = temporaryCacheDirectories.create('devflare-previews-workspace-') + writeFileSync(join(workspaceDir, 'package.json'), JSON.stringify({ + name: 'preview-workspace', + private: true, + type: 'module', + workspaces: ['apps/*'] + }, null, '\t'), 'utf-8') + + const docsDir = join(workspaceDir, 'apps', 'docs') + const testingDir = join(workspaceDir, 'apps', 'testing') + mkdirSync(docsDir, { recursive: true }) + mkdirSync(testingDir, { recursive: true }) + writePreviewProject(docsDir, 'docs-worker') + writePreviewProject(testingDir, 'testing-worker') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'docs-worker', + created_on: '2026-04-12T10:00:00.000Z', + modified_on: '2026-04-12T10:05:00.000Z' + }, + { + id: 'docs-worker-pr-42', + created_on: '2026-04-12T10:01:00.000Z', + modified_on: '2026-04-12T10:06:00.000Z' + }, + { + id: 'testing-worker', + created_on: '2026-04-12T10:02:00.000Z', + modified_on: '2026-04-12T10:07:00.000Z' + }, + { + id: 'testing-worker-next', + created_on: '2026-04-12T10:03:00.000Z', + modified_on: '2026-04-12T10:08:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 4, + total_count: 4 + }) + } + + if (url.endsWith('/accounts/acc_123/workers/subdomain')) { + return jsonResponse({ + subdomain: 'demo-subdomain' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: [], + options: {} + }, + logger as any, + { cwd: workspaceDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('configured worker families 2'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('worker family docs-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('worker family testing-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('docs-worker.demo-subdomain.workers.dev'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('testing-worker-next.demo-subdomain.workers.dev'))).toBe(true) + }) + + test('requires --account when a monorepo root discovers multiple Cloudflare accounts', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const workspaceDir = temporaryCacheDirectories.create('devflare-previews-workspace-accounts-') + writeFileSync(join(workspaceDir, 'package.json'), JSON.stringify({ + name: 'preview-workspace-accounts', + private: true, + type: 'module', + workspaces: ['apps/*'] + }, null, '\t'), 'utf-8') + + const docsDir = join(workspaceDir, 'apps', 'docs') + const testingDir = join(workspaceDir, 'apps', 'testing') + mkdirSync(docsDir, { recursive: true }) + mkdirSync(testingDir, { recursive: true }) + writePreviewProject(docsDir, 'docs-worker', 'acc_123') + writePreviewProject(testingDir, 'testing-worker', 'acc_456') + + const logger = createLogger() + const result = await runPreviewsCommand( + { + command: 'previews', + args: [], + options: {} + }, + logger as any, + { cwd: workspaceDir } + ) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => { + return message.args.join(' ').includes('Multiple Cloudflare account ids were discovered across local Devflare configs') + })).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/productions.test.ts b/packages/devflare/tests/unit/cli/productions.test.ts new file mode 100644 index 0000000..ee2100f --- /dev/null +++ b/packages/devflare/tests/unit/cli/productions.test.ts @@ -0,0 +1,313 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProductionsCommand } from '../../../src/cli/commands/productions' +import { clearDependencies, setDependencies } from '../../../src/cli/dependencies' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger, renderMessages } from '../../helpers/mock-logger' +import { createCliDependencies, successResult } from '../../helpers/process-runner' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN +const temporaryDirectories = new Set() + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } + clearDependencies() + for (const directory of temporaryDirectories) { + rmSync(directory, { recursive: true, force: true }) + } + temporaryDirectories.clear() +}) + +describe('productions command', () => { + test('lists active production deployments for a configured worker family', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const projectDir = mkdtempSync(join(tmpdir(), 'devflare-productions-family-')) + temporaryDirectories.add(projectDir) + writeFileSync(join(projectDir, 'package.json'), JSON.stringify({ + name: 'demo-productions-family', + type: 'module' + }, null, '\t'), 'utf-8') + writeFileSync(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'demo-worker', + accountId: 'acc_123', + compatibilityDate: '2026-04-12', + bindings: { + services: { + AUTH_SERVICE: { service: 'demo-auth-service' } + } + } + } + `, 'utf-8') + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'demo-worker', + created_on: '2026-04-12T10:00:00.000Z', + modified_on: '2026-04-12T10:05:00.000Z' + }, + { + id: 'demo-auth-service', + created_on: '2026-04-12T10:00:00.000Z', + modified_on: '2026-04-12T10:04:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 2, + total_count: 2 + }) + } + + if (url.endsWith('/accounts/acc_123/workers/subdomain')) { + return jsonResponse({ + subdomain: 'demo-subdomain' + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deployment-main', + created_on: '2026-04-12T11:00:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: '11111111-1111-4111-8111-111111111111' + } + ] + } + ] + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-auth-service/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deployment-auth', + created_on: '2026-04-12T11:01:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: '22222222-2222-4222-8222-222222222222' + } + ] + } + ] + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runProductionsCommand( + { + command: 'productions', + args: [], + options: { + account: 'acc_123' + } + }, + logger as any, + { cwd: projectDir } + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('worker family demo-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('related'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Productions (2)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('demo-worker.demo-subdomain.workers.dev'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('11111111-111'))).toBe(true) + }) + + test('lists recent production versions for a worker', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/versions?page=1&per_page=100')) { + return jsonResponse({ + items: [ + { + id: '11111111-1111-4111-8111-111111111111', + metadata: { + hasPreview: false, + modified_on: '2026-04-12T11:00:00.000Z', + source: 'wrangler' + } + }, + { + id: '33333333-3333-4333-8333-333333333333', + metadata: { + hasPreview: false, + modified_on: '2026-04-11T11:00:00.000Z', + source: 'wrangler' + } + } + ] + }) + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deployment-main', + created_on: '2026-04-12T11:05:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: '11111111-1111-4111-8111-111111111111' + } + ] + } + ] + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runProductionsCommand( + { + command: 'productions', + args: ['versions'], + options: { + account: 'acc_123', + worker: 'demo-worker' + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('worker demo-worker'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Versions (2)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('active'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('stored'))).toBe(true) + }) + + test('rolls a worker back with Wrangler when apply is set', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const executions: Array<{ command: string; args: string[] }> = [] + setDependencies(createCliDependencies({ + exec: async (command, args = []) => { + executions.push({ command, args }) + return successResult() + }, + spawn: mock(() => { + throw new Error('spawn should not be called in productions rollback test') + }) as any + })) + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/deployments')) { + return jsonResponse({ + deployments: [ + { + id: 'deployment-main', + created_on: '2026-04-12T11:05:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: '11111111-1111-4111-8111-111111111111' + } + ] + } + ] + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runProductionsCommand( + { + command: 'productions', + args: ['rollback'], + options: { + account: 'acc_123', + worker: 'demo-worker', + apply: true + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(executions).toHaveLength(1) + expect(executions[0]?.command).toBe('bunx') + expect(executions[0]?.args).toEqual([ + 'wrangler', + 'rollback', + '--name', + 'demo-worker', + '--message', + 'Rolled back demo-worker via devflare productions rollback' + ]) + expect(renderedMessages.some((message) => message.includes('Rolled back production deployment for demo-worker'))).toBe(true) + }) + + test('deletes a production worker script when apply is set', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker') && init?.method === 'DELETE') { + return jsonResponse({}) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createLogger() + const result = await runProductionsCommand( + { + command: 'productions', + args: ['delete'], + options: { + account: 'acc_123', + worker: 'demo-worker', + apply: true + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(renderedMessages.some((message) => message.includes('Deleted production Worker script demo-worker'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/resolve-deploy-impact.test.ts b/packages/devflare/tests/unit/cli/resolve-deploy-impact.test.ts new file mode 100644 index 0000000..ecb738b --- /dev/null +++ b/packages/devflare/tests/unit/cli/resolve-deploy-impact.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { + createGlobalDependencyPatterns, + matchesAnyPattern, + SHARED_DEPLOY_INFRASTRUCTURE_PATTERNS + // @ts-ignore - JS module with no declarations +} from '../../../../../.github/scripts/resolve-deploy-impact.mjs' + +describe('resolve deploy impact script', () => { + test('treats shared deploy infrastructure as a global invalidation input', () => { + const patterns = createGlobalDependencyPatterns({ + globalDependencies: [] + }) + + expect(patterns).toContain('package.json') + expect(patterns).toContain('turbo.json') + + for (const pattern of SHARED_DEPLOY_INFRASTRUCTURE_PATTERNS) { + expect(patterns).toContain(pattern) + } + + expect(matchesAnyPattern('.github/actions/devflare-deploy/action.yml', patterns)).toBe(true) + expect(matchesAnyPattern('.github/actions/devflare-deploy-impact/action.yml', patterns)).toBe(true) + expect(matchesAnyPattern('.github/actions/devflare-github-feedback/action.yml', patterns)).toBe(true) + expect(matchesAnyPattern('.github/actions/devflare-setup-workspace/action.yml', patterns)).toBe(true) + expect(matchesAnyPattern('.github/scripts/resolve-deploy-impact.mjs', patterns)).toBe(true) + expect(matchesAnyPattern('.github/scripts/verify-testing-preview-deployment.ts', patterns)).toBe(true) + expect(matchesAnyPattern('.github/workflows/preview.yml', patterns)).toBe(true) + expect(matchesAnyPattern('.github/workflows/documentation-production.yml', patterns)).toBe(true) + }) + + test('keeps turbo global dependencies alongside shared deploy infrastructure', () => { + const patterns = createGlobalDependencyPatterns({ + globalDependencies: ['.env.example', 'config/*.json'] + }) + + expect(matchesAnyPattern('.env.example', patterns)).toBe(true) + expect(matchesAnyPattern('config/dev.json', patterns)).toBe(true) + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cli/secrets.test.ts b/packages/devflare/tests/unit/cli/secrets.test.ts new file mode 100644 index 0000000..25af6d9 --- /dev/null +++ b/packages/devflare/tests/unit/cli/secrets.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' + +import { runCli } from '../../../src/cli' +import { listLocalSecrets, readLocalSecret } from '../../../src/secrets/local-secrets' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-secrets-cli-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('secrets command', () => { + test('stores a local Secrets Store value without printing the secret', async () => { + const cwd = createTempDir() + + const result = await runCli([ + 'secrets', + '--local', + '--store', + 'store-123', + '--name', + 'api-token', + '--value', + 'local-secret' + ], { + cwd, + silent: true + }) + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('store-123/api-token') + expect(result.output).not.toContain('local-secret') + expect(readLocalSecret({ cwd, storeId: 'store-123', name: 'api-token' })).toBe('local-secret') + }) + + test('lists and deletes local Secrets Store names without exposing values', async () => { + const cwd = createTempDir() + await runCli([ + 'secrets', + '--local', + '--store', + 'store-123', + '--name', + 'api-token', + '--value', + 'local-secret' + ], { + cwd, + silent: true + }) + + const listResult = await runCli(['secrets', '--local', '--store', 'store-123', '--list'], { + cwd, + silent: true + }) + expect(listResult.exitCode).toBe(0) + expect(listResult.output).toContain('store-123/api-token') + expect(listResult.output).not.toContain('local-secret') + + const deleteResult = await runCli([ + 'secrets', + '--local', + '--store', + 'store-123', + '--name', + 'api-token', + '--delete' + ], { + cwd, + silent: true + }) + + expect(deleteResult.exitCode).toBe(0) + expect(deleteResult.output).toBe('store-123/api-token') + expect(listLocalSecrets({ cwd, storeId: 'store-123' })).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/cli/token.test.ts b/packages/devflare/tests/unit/cli/token.test.ts new file mode 100644 index 0000000..6ed3576 --- /dev/null +++ b/packages/devflare/tests/unit/cli/token.test.ts @@ -0,0 +1,591 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { runTokenCommand } from '../../../src/cli/commands/token' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger as createBaseLogger, stripAnsi, type TestLogger as BaseTestLogger } from '../../helpers/mock-logger' + +interface TestLogger extends BaseTestLogger { + prompt: ReturnType +} + +interface RecordedTokenRequest { + url: string + authorization?: string | null + method: string + body?: string +} + +function createPromptLogger(options: { promptResult?: string | symbol } = {}): TestLogger { + const logger = createBaseLogger() as TestLogger + const prompt = mock(async (...args: unknown[]) => { + logger.messages.push({ level: 'prompt', args }) + return options.promptResult ?? 'preview' + }) + + logger.prompt = prompt + return logger +} + +function createPaginatedResponse(items: Array>): Response { + return jsonResponse(items, { + page: 1, + per_page: 50, + total_pages: 1, + count: items.length, + total_count: items.length + }) +} + +function createAccountListResponse(): Response { + return createPaginatedResponse([ + { + id: 'acc_123', + name: 'Devflare Account', + type: 'standard' + } + ]) +} + +function captureRecordedTokenRequest( + requests: RecordedTokenRequest[], + input: RequestInfo | URL, + init?: RequestInit +): RecordedTokenRequest { + const request = { + url: String(input), + authorization: new Headers(init?.headers).get('Authorization'), + method: init?.method ?? 'GET', + body: typeof init?.body === 'string' ? init.body : undefined + } + requests.push(request) + return request +} + +function renderMessages(logger: BaseTestLogger): string[] { + return logger.messages.map((message) => stripAnsi(message.args.join(' '))) +} + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('token command', () => { + test('creates a new Devflare-managed account-owned token from a bootstrap token', async () => { + const requests: RecordedTokenRequest[] = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const { url } = captureRecordedTokenRequest(requests, input, init) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens/permission_groups?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'group-workers', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'group-kv', + name: 'Workers KV Storage Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'group-workers-routes', + name: 'Workers Routes Write', + scopes: ['com.cloudflare.api.account.zone'] + }, + { + id: 'group-nope', + name: 'Account WAF Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + } + + if (url.endsWith('/accounts/acc_123/tokens')) { + return jsonResponse({ + id: 'token_123', + name: 'devflare-custom', + value: 'cfat_1234567890' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + new: 'custom' + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + const createRequest = requests.find((request) => request.method === 'POST') + const createRequestBody = JSON.parse(createRequest?.body ?? '{}') as { + name?: string + policies?: Array<{ + resources?: Record + permission_groups?: Array<{ id: string }> + }> + } + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('cfat_1234567890') + expect(requests).toHaveLength(3) + expect(requests.every((request) => request.authorization === 'Bearer bootstrap-token')).toBe(true) + expect(createRequestBody.name).toBe('devflare-custom') + expect(createRequestBody.policies).toEqual([ + { + effect: 'allow', + resources: { + 'com.cloudflare.api.account.acc_123': '*' + }, + permission_groups: [ + { id: 'group-workers' }, + { id: 'group-kv' } + ] + }, + { + effect: 'allow', + resources: { + 'com.cloudflare.api.account.acc_123': { + 'com.cloudflare.api.account.zone.*': '*' + } + }, + permission_groups: [ + { id: 'group-workers-routes' } + ] + } + ]) + expect(renderedMessages.some((message) => message.includes('Created devflare-custom'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Permission groups: 3 Devflare-relevant account/zone-scoped selected from 4 available'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('cfat_1234567890'))).toBe(true) + }) + + test('creates an all-flags token from reusable account and zone-scoped permissions only', async () => { + const requests: RecordedTokenRequest[] = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const { url } = captureRecordedTokenRequest(requests, input, init) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens/permission_groups?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'group-workers-account', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'group-queues-account', + name: 'Queues Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'group-workers-zone', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account.zone'] + }, + { + id: 'group-user-read', + name: 'User Details Read', + scopes: ['com.cloudflare.api.user'] + }, + { + id: 'group-tokens', + name: 'Account API Tokens Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + } + + if (url.endsWith('/accounts/acc_123/tokens')) { + return jsonResponse({ + id: 'token_999', + name: 'devflare-everything', + value: 'cfat_all_flags' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + new: 'everything', + 'all-flags': true + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + const createRequest = requests.find((request) => request.method === 'POST') + const createRequestBody = JSON.parse(createRequest?.body ?? '{}') as { + name?: string + policies?: Array<{ + resources?: Record + permission_groups?: Array<{ id: string }> + }> + } + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('cfat_all_flags') + expect(createRequestBody.name).toBe('devflare-everything') + expect(createRequestBody.policies).toEqual([ + { + effect: 'allow', + resources: { + 'com.cloudflare.api.account.acc_123': '*' + }, + permission_groups: [ + { id: 'group-workers-account' }, + { id: 'group-queues-account' } + ] + }, + { + effect: 'allow', + resources: { + 'com.cloudflare.api.account.acc_123': { + 'com.cloudflare.api.account.zone.*': '*' + } + }, + permission_groups: [ + { id: 'group-workers-zone' } + ] + } + ]) + expect(renderedMessages.some((message) => message.includes('Permission groups: 3 reusable account/zone-scoped selected from 5 available'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('user-scoped groups are skipped automatically'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Account API Tokens permissions are still excluded'))).toBe(true) + }) + + test('prompts for the token name when --new is passed without a value', async () => { + const requests: Array<{ url: string; method: string; body?: string }> = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + requests.push({ + url, + method: init?.method ?? 'GET', + body: typeof init?.body === 'string' ? init.body : undefined + }) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens/permission_groups?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'group-workers', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + } + + if (url.endsWith('/accounts/acc_123/tokens')) { + return jsonResponse({ + id: 'token_456', + name: 'devflare-preview', + value: 'cfat_prompted' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger({ promptResult: 'preview' }) + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + new: true + } + }, + logger as any, + {} + ) + const createRequest = requests.find((request) => request.method === 'POST') + const createRequestBody = JSON.parse(createRequest?.body ?? '{}') as { name?: string } + + expect(result.exitCode).toBe(0) + expect(logger.prompt).toHaveBeenCalledTimes(1) + expect(createRequestBody.name).toBe('devflare-preview') + }) + + test('lists only Devflare-managed account-owned tokens', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'token_123', + name: 'devflare-preview', + status: 'active', + modified_on: '2026-04-08T10:15:00.000Z' + }, + { + id: 'token_124', + name: 'manual-token', + status: 'active', + modified_on: '2026-04-08T10:10:00.000Z' + } + ]) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + list: true + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('preview') + expect(renderedMessages.some((message) => message.includes('Devflare-managed tokens'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('preview'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('devflare-preview'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('manual-token'))).toBe(false) + }) + + test('rolls a normalized Devflare-managed token name without deleting and recreating it', async () => { + const requests: RecordedTokenRequest[] = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const { url } = captureRecordedTokenRequest(requests, input, init) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'token_123', + name: 'devflare-preview', + status: 'active' + }, + { + id: 'token_124', + name: 'manual-token', + status: 'active' + } + ]) + } + + if (init?.method === 'PUT' && url.endsWith('/accounts/acc_123/tokens/token_123/value')) { + return jsonResponse('cfat_rolled_secret') + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + roll: 'preview' + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + const rollRequest = requests.find((request) => request.method === 'PUT') + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('cfat_rolled_secret') + expect(rollRequest?.url).toBe('https://api.cloudflare.com/client/v4/accounts/acc_123/tokens/token_123/value') + expect(rollRequest?.body).toBe('{}') + expect(renderedMessages.some((message) => message.includes('Rolled 1 Devflare-managed token(s) named devflare-preview'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Cloudflare only returns the new token secret once. Store it safely now.'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('cfat_rolled_secret'))).toBe(true) + }) + + test('deletes a normalized Devflare-managed token name', async () => { + const deletedUrls: string[] = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'token_123', + name: 'devflare-preview', + status: 'active' + }, + { + id: 'token_124', + name: 'manual-token', + status: 'active' + } + ]) + } + + if (init?.method === 'DELETE' && url.endsWith('/accounts/acc_123/tokens/token_123')) { + deletedUrls.push(url) + return jsonResponse({ id: 'token_123' }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + delete: 'preview' + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('token_123') + expect(deletedUrls).toEqual([ + 'https://api.cloudflare.com/client/v4/accounts/acc_123/tokens/token_123' + ]) + expect(renderedMessages.some((message) => message.includes('Deleted 1 Devflare-managed token(s) named devflare-preview'))).toBe(true) + }) + + test('deletes all Devflare-managed account-owned tokens without touching other tokens', async () => { + const deletedUrls: string[] = [] + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (url.includes('/accounts?page=1&per_page=50')) { + return createAccountListResponse() + } + + if (url.includes('/accounts/acc_123/tokens?page=1&per_page=50')) { + return createPaginatedResponse([ + { + id: 'token_123', + name: 'devflare-preview-a', + status: 'active' + }, + { + id: 'token_124', + name: 'manual-token', + status: 'active' + }, + { + id: 'token_125', + name: 'devflare-preview-b', + status: 'disabled' + } + ]) + } + + if (init?.method === 'DELETE' && url.includes('/accounts/acc_123/tokens/token_')) { + deletedUrls.push(url) + return jsonResponse({ id: url.split('/').pop() }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: { + 'delete-all': true + } + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(0) + expect(result.output).toBe('token_123\ntoken_125') + expect(deletedUrls).toEqual([ + 'https://api.cloudflare.com/client/v4/accounts/acc_123/tokens/token_123', + 'https://api.cloudflare.com/client/v4/accounts/acc_123/tokens/token_125' + ]) + expect(renderedMessages.some((message) => message.includes('Deleted 2 Devflare-managed token(s)'))).toBe(true) + expect(renderedMessages.some((message) => message.includes('Left 1 non-Devflare token(s) untouched.'))).toBe(true) + }) + + test('requires a bootstrap token argument', async () => { + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: [], + options: {} + }, + logger as any, + {} + ) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.level === 'error')).toBe(false) + expect(logger.messages.some((message) => stripAnsi(message.args.join(' ')).includes('devflare tokens '))).toBe(true) + expect(stripAnsi(logger.messages.at(-1)?.args.join(' ') ?? 'missing')).toBe('') + }) + + test('shows a usage summary without logging an error when no token operation is selected', async () => { + const logger = createPromptLogger() + const result = await runTokenCommand( + { + command: 'tokens', + args: ['bootstrap-token'], + options: {} + }, + logger as any, + {} + ) + const renderedMessages = renderMessages(logger) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.level === 'error')).toBe(false) + expect(renderedMessages.some((message) => message.includes('Choose one token operation: --list, --new, --roll, --delete, or --delete-all.'))).toBe(false) + expect(renderedMessages.some((message) => message.includes('Usage: devflare tokens '))).toBe(true) + expect(renderedMessages.some((message) => message.includes('--roll [name]'))).toBe(true) + expect(renderedMessages.at(-1)).toBe('') + }) +}) diff --git a/packages/devflare/tests/unit/cli/type-generation/generator.test.ts b/packages/devflare/tests/unit/cli/type-generation/generator.test.ts new file mode 100644 index 0000000..0471a58 --- /dev/null +++ b/packages/devflare/tests/unit/cli/type-generation/generator.test.ts @@ -0,0 +1,204 @@ +// ============================================================================= +// CLI type-generation โ€” generateBindingTypes fixture (P1-codegen) +// ============================================================================= +// +// Pins the public shape of the generated `env.d.ts` source: a global +// `interface DevflareEnv` containing one typed member per binding from the +// resolved devflare config, plus typed config vars inferred from the user's +// config. This is the contract relied on by: +// - src/env.ts (declares `interface DevflareEnv {}`) +// - src/test/simple-context.ts (consumes DevflareEnv in test contexts) +// - src/runtime/exports.ts (re-exports DevflareEnv to user code) +// +// Keeping the shape covered by a fixture-based test avoids silent regressions +// when the generator's binding-walker changes. +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { generateBindingTypes } from '../../../../src/cli/commands/type-generation/generator' + +const fixtureConfig = { + bindings: { + kv: { MY_KV: { id: 'kv-id', preview_id: 'kv-prev' } }, + d1: { MY_DB: { id: 'db-1' } }, + r2: { MY_BUCKET: 'bucket-1' }, + queues: { producers: { MY_QUEUE: 'queue-1' } }, + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { limit: 100, period: 60 as const } + } + }, + versionMetadata: { binding: 'CF_VERSION_METADATA' }, + workerLoaders: { + LOADER: {} + }, + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123' + } + }, + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers' + } + }, + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow' + } + }, + pipelines: { + EVENTS: { + pipeline: 'events-stream' + } + }, + images: { + IMAGES: { + remote: true + } + }, + media: { + MEDIA: { + remote: true + } + }, + artifacts: { + ARTIFACTS: { + namespace: 'default' + } + }, + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + }, + ai: { + binding: 'AI', + remote: true, + staging: true + }, + aiSearchNamespaces: { + AI_SEARCH: { + namespace: 'default', + remote: true + } + }, + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs', + remote: true + } + }, + browser: { + MY_BROWSER: { + remote: true + } + } + }, + rules: [ + { type: 'Text', globs: ['**/*.txt'] }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] }, + { type: 'ESModule', globs: ['**/*.mjs'] } + ], + vars: { MY_VAR: 'hello' }, + secrets: { MY_SECRET: { required: true } } +} + +describe('generateBindingTypes โ€” P1-codegen fixture', () => { + const generated = generateBindingTypes(fixtureConfig, [], [], [], '/tmp/fake-project') + + test('emits the canonical "DO NOT EDIT" header', () => { + expect(generated.startsWith('// Generated by devflare - DO NOT EDIT')).toBe(true) + }) + + test('imports the workers-types it actually uses', () => { + expect(generated).toContain("import type {") + expect(generated).toContain("from '@cloudflare/workers-types'") + expect(generated).toContain("import type { Pipeline } from 'cloudflare:pipelines'") + expect(generated).toContain('KVNamespace') + expect(generated).toContain('D1Database') + expect(generated).toContain('R2Bucket') + expect(generated).toContain('Queue') + expect(generated).toContain('RateLimit') + expect(generated).toContain('WorkerVersionMetadata') + expect(generated).toContain('WorkerLoader') + expect(generated).toContain('DispatchNamespace') + expect(generated).toContain('Workflow') + expect(generated).toContain('ImagesBinding') + expect(generated).toContain('MediaBinding') + expect(generated).toContain('Artifacts') + expect(generated).toContain('SecretsStoreSecret') + expect(generated).toContain('Ai') + expect(generated).toContain('AiSearchNamespace') + expect(generated).toContain('AiSearchInstance') + expect(generated).toContain('Fetcher') + }) + + test('declares the DevflareEnv interface with one member per binding', () => { + expect(generated).toContain('declare global {') + expect(generated).toContain("import type { InferConfigVars } from 'devflare/config'") + expect(generated).toContain( + "type __DevflareConfigVars = InferConfigVars>" + ) + expect(generated).toContain('interface DevflareVars extends __DevflareConfigVars {}') + expect(generated).toContain('interface DevflareEnv extends __DevflareConfigVars {') + expect(generated).toContain('MY_KV: KVNamespace') + expect(generated).toContain('MY_DB: D1Database') + expect(generated).toContain('MY_BUCKET: R2Bucket') + expect(generated).toContain('MY_QUEUE: Queue') + expect(generated).toContain('MY_RATE_LIMITER: RateLimit') + expect(generated).toContain('CF_VERSION_METADATA: WorkerVersionMetadata') + expect(generated).toContain('LOADER: WorkerLoader') + expect(generated).toContain('API_CERT: Fetcher') + expect(generated).toContain('DISPATCHER: DispatchNamespace') + expect(generated).toContain('ORDER_WORKFLOW: Workflow') + expect(generated).toContain('EVENTS: Pipeline') + expect(generated).toContain('IMAGES: ImagesBinding') + expect(generated).toContain('MEDIA: MediaBinding') + expect(generated).toContain('ARTIFACTS: Artifacts') + expect(generated).toContain('API_TOKEN: SecretsStoreSecret') + expect(generated).toContain('AI_SEARCH: AiSearchNamespace') + expect(generated).toContain('DOCS_SEARCH: AiSearchInstance') + expect(generated).toContain('MY_BROWSER: Fetcher') + expect(generated).toContain('MY_SECRET: string') + }) + + test('emits ambient module declarations for native module rules', () => { + expect(generated).toContain("declare module '*.txt'") + expect(generated).toContain('const value: string') + expect(generated).toContain("declare module '*.bin'") + expect(generated).toContain('const value: ArrayBuffer') + expect(generated).toContain("declare module '*.wasm'") + expect(generated).toContain('const value: WebAssembly.Module') + expect(generated).not.toContain("declare module '*.mjs'") + }) + + test('emits the Entrypoints type fallback when no entrypoints were discovered', () => { + expect(generated).toContain('export type Entrypoints = string') + }) + + test('emits a typed Entrypoints union when entrypoints are discovered', () => { + const withEntrypoints = generateBindingTypes( + fixtureConfig, + [], + [ + { className: 'WorkerA', filePath: '/tmp/fake-project/src/ep.workerA.ts' }, + { className: 'WorkerB', filePath: '/tmp/fake-project/src/ep.workerB.ts' } + ], + [], + '/tmp/fake-project' + ) + expect(withEntrypoints).toContain("export type Entrypoints = 'WorkerA' | 'WorkerB'") + }) + + test('empty config still emits a valid empty DevflareEnv block', () => { + const empty = generateBindingTypes({}, [], [], [], '/tmp/fake-project') + expect(empty).toContain('declare global {') + expect(empty).toContain('interface DevflareEnv {') + expect(empty).toContain('export type Entrypoints = string') + }) +}) diff --git a/packages/devflare/tests/unit/cli/verify-testing-preview-deployment.test.ts b/packages/devflare/tests/unit/cli/verify-testing-preview-deployment.test.ts new file mode 100644 index 0000000..c6ff3b0 --- /dev/null +++ b/packages/devflare/tests/unit/cli/verify-testing-preview-deployment.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from 'bun:test' +import { + collectTestingPreviewVerificationErrors, + DEFAULT_EXPECTED_APP_NAME, + DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + loadTestingPreviewConfig, + REQUIRED_MAIN_BINDINGS +} from '../../../../../.github/scripts/verify-testing-preview-deployment' + +describe('testing preview deployment verifier', () => { + test('loads preview config without requiring Cloudflare resource resolution', async () => { + const config = await loadTestingPreviewConfig('pr-1') + + expect(config.name).toBe('devflare-testing-binding-matrix-pr-1') + expect(config.vars?.APP_NAME).toBe(DEFAULT_EXPECTED_APP_NAME) + expect(config.vars?.DEPLOYMENT_CHANNEL).toBe(DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL) + expect(config.bindings?.hyperdrive?.POSTGRES).toEqual({ + name: 'devflare-testing-pr-1', + previewFallback: 'base' + }) + }) + + test('accepts a preview deployment snapshot with the expected workers and bindings', () => { + const workerName = 'devflare-testing-binding-matrix-next' + + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: workerName, + expectedAuthWorkerName: 'devflare-testing-auth-service-next', + expectedSearchWorkerName: 'devflare-testing-search-service-next', + resolvedWorkerName: workerName, + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + availableWorkers: [ + workerName, + 'devflare-testing-auth-service-next', + 'devflare-testing-search-service-next' + ], + versionId: 'version-123', + bindingsInspected: true, + bindingNames: [...REQUIRED_MAIN_BINDINGS] + }) + + expect(errors).toEqual([]) + }) + + test('reports preview config drift and missing control-plane metadata when binding inspection never ran', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix', + resolvedAppName: 'testing-binding-matrix', + resolvedDeploymentChannel: 'development', + availableWorkers: ['devflare-testing-binding-matrix'], + versionId: undefined, + bindingsInspected: false, + bindingNames: ['SESSIONS', 'AUTH_SERVICE'] + }) + + expect(errors).toContain( + 'Resolved preview worker name was "devflare-testing-binding-matrix" instead of "devflare-testing-binding-matrix-pr-1".' + ) + expect(errors).toContain( + 'Resolved APP_NAME was "testing-binding-matrix" instead of "testing-binding-matrix-preview".' + ) + expect(errors).toContain('Resolved DEPLOYMENT_CHANNEL was "development" instead of "preview".') + expect(errors).toContain( + 'Expected deployed preview worker "devflare-testing-binding-matrix-pr-1" was not found in the Cloudflare account.' + ) + expect(errors).toContain( + 'Expected preview sidecar worker "devflare-testing-auth-service-pr-1" was not found in the Cloudflare account.' + ) + expect(errors).toContain( + 'Expected preview sidecar worker "devflare-testing-search-service-pr-1" was not found in the Cloudflare account.' + ) + expect(errors).toContain( + 'Could not resolve an active deployment version for "devflare-testing-binding-matrix-pr-1".' + ) + }) + + test('reports missing bindings when a preview Worker version was inspected', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix-pr-1', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + availableWorkers: [ + 'devflare-testing-binding-matrix-pr-1', + 'devflare-testing-auth-service-pr-1', + 'devflare-testing-search-service-pr-1' + ], + versionId: 'version-123', + bindingsInspected: true, + bindingNames: ['SESSIONS', 'AUTH_SERVICE'] + }) + + expect(errors).toContain( + 'Expected binding "SESSION_ROOM" was missing from the deployed preview Worker version.' + ) + expect(errors).toContain( + 'Expected binding "POSTGRES" was missing from the deployed preview Worker version.' + ) + }) + + test('does not fail just because preview sidecar deploy steps were skipped on this run', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix-pr-1', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + availableWorkers: [ + 'devflare-testing-binding-matrix-pr-1', + 'devflare-testing-auth-service-pr-1', + 'devflare-testing-search-service-pr-1' + ], + versionId: 'version-456', + bindingsInspected: true, + bindingNames: [...REQUIRED_MAIN_BINDINGS] + }) + + expect(errors).toEqual([]) + }) + + test('reports a missing worker even when binding inspection succeeded against stale Wrangler metadata', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix-pr-1', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + availableWorkers: [], + versionId: 'version-789', + bindingsInspected: true, + bindingNames: [...REQUIRED_MAIN_BINDINGS] + }) + + expect(errors).toContain( + 'Expected deployed preview worker "devflare-testing-binding-matrix-pr-1" was not found in the Cloudflare account.' + ) + }) + + test('accepts a named preview deploy when Cloudflare withholds preview version metadata', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-next', + expectedAuthWorkerName: 'devflare-testing-auth-service-next', + expectedSearchWorkerName: 'devflare-testing-search-service-next', + resolvedWorkerName: 'devflare-testing-binding-matrix-next', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + previewUrl: 'https://devflare-testing-binding-matrix-next.example.workers.dev', + previewStatus: { + appName: DEFAULT_EXPECTED_APP_NAME, + deploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + hasDurableObjectBindings: true, + hasServiceBindings: true, + hasVectorizeBindings: true, + hasAnalyticsBindings: true, + hasSendEmailBindings: true, + hasHyperdriveBinding: true + }, + availableWorkers: [ + 'devflare-testing-auth-service-next', + 'devflare-testing-binding-matrix-next', + 'devflare-testing-search-service-next' + ], + versionId: undefined, + bindingsInspected: false, + bindingNames: [] + }) + + expect(errors).toEqual([]) + }) + + test('reports missing preview sidecars when Cloudflare withholds preview version metadata', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix-pr-1', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + previewUrl: 'https://devflare-testing-binding-matrix-pr-1.example.workers.dev', + previewStatus: { + appName: DEFAULT_EXPECTED_APP_NAME, + deploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + hasDurableObjectBindings: true, + hasServiceBindings: true, + hasVectorizeBindings: true, + hasAnalyticsBindings: true, + hasSendEmailBindings: true, + hasHyperdriveBinding: true + }, + availableWorkers: [ + 'devflare-testing-binding-matrix-pr-1', + 'devflare-testing-search-service-pr-1' + ], + versionId: undefined, + bindingsInspected: false, + bindingNames: [] + }) + + expect(errors).toContain( + 'Expected preview sidecar worker "devflare-testing-auth-service-pr-1" was not found in the Cloudflare account.' + ) + }) + + test('reports preview status endpoint errors with the preview URL context intact', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-pr-1', + expectedAuthWorkerName: 'devflare-testing-auth-service-pr-1', + expectedSearchWorkerName: 'devflare-testing-search-service-pr-1', + resolvedWorkerName: 'devflare-testing-binding-matrix-pr-1', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + previewUrl: 'https://devflare-testing-binding-matrix-pr-1.example.workers.dev', + previewStatusError: 'Preview status endpoint returned 503 Service Unavailable.', + availableWorkers: [ + 'devflare-testing-binding-matrix-pr-1', + 'devflare-testing-auth-service-pr-1', + 'devflare-testing-search-service-pr-1' + ], + versionId: undefined, + bindingsInspected: false, + bindingNames: [] + }) + + expect(errors).toContain( + 'Could not load the preview status endpoint from "https://devflare-testing-binding-matrix-pr-1.example.workers.dev": Preview status endpoint returned 503 Service Unavailable.' + ) + }) + + test('accepts complete Wrangler metadata when live probes are blocked by Cloudflare Access', () => { + const errors = collectTestingPreviewVerificationErrors({ + expectedAppName: DEFAULT_EXPECTED_APP_NAME, + expectedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + expectedWorkerName: 'devflare-testing-binding-matrix-next', + expectedAuthWorkerName: 'devflare-testing-auth-service-next', + expectedSearchWorkerName: 'devflare-testing-search-service-next', + resolvedWorkerName: 'devflare-testing-binding-matrix-next', + resolvedAppName: DEFAULT_EXPECTED_APP_NAME, + resolvedDeploymentChannel: DEFAULT_EXPECTED_DEPLOYMENT_CHANNEL, + previewUrl: 'https://devflare-testing-binding-matrix-next.example.workers.dev', + previewHealth: { + ok: false, + status: 302, + body: '', + redirectedToAccess: true, + locationHeader: 'https://example.cloudflareaccess.com/cdn-cgi/access/login' + }, + previewStatusAccessBlocked: true, + previewStatusError: + 'Cloudflare Access intercepted https://devflare-testing-binding-matrix-next.example.workers.dev/status (Location: https://example.cloudflareaccess.com/cdn-cgi/access/login). Cannot read /status.', + availableWorkers: ['devflare-testing-binding-matrix-next'], + versionId: 'version-123', + bindingsInspected: true, + bindingNames: [...REQUIRED_MAIN_BINDINGS] + }) + + expect(errors).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/cli/worker.test.ts b/packages/devflare/tests/unit/cli/worker.test.ts new file mode 100644 index 0000000..0d9a910 --- /dev/null +++ b/packages/devflare/tests/unit/cli/worker.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { runWorkerCommand } from '../../../src/cli/commands/worker' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { createLogger } from '../../helpers/mock-logger' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN +const tempDirectories: string[] = [] + +afterEach(async () => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } + + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function createTempMonorepo(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'devflare-worker-rename-')) + tempDirectories.push(directory) + return directory +} + +async function writeConfigFile(rootDir: string, relativePath: string, workerName: string): Promise { + const configPath = join(rootDir, relativePath) + await mkdir(dirname(configPath), { recursive: true }) + await writeFile( + configPath, + `export default { + name: '${workerName}', + compatibilityDate: '2026-04-08', + accountId: 'acc_123' +} +`, + 'utf-8' + ) + return configPath +} + +function mockRenameWorkerApi(fromName: string, toName: string): void { + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if (method === 'GET' && url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + return jsonResponse([ + { + id: 'worker_1', + name: fromName, + created_on: '2026-04-08T00:00:00.000Z', + modified_on: '2026-04-08T00:00:00.000Z' + } + ]) + } + + if (method === 'PATCH' && url.endsWith(`/accounts/acc_123/workers/workers/${fromName}`)) { + return jsonResponse({ + id: 'worker_1', + name: toName + }) + } + + throw new Error(`Unexpected fetch request: ${method} ${url}`) + }) as unknown as typeof fetch +} + +async function runRenameWorker(rootDir: string, fromName: string, toName: string, logger: ReturnType) { + return await runWorkerCommand( + { + command: 'worker', + args: ['rename', fromName], + options: { + to: toName + } + }, + logger as any, + { cwd: rootDir } + ) +} + +describe('worker command', () => { + test('renames a remote Worker and updates the matching nested devflare config', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const rootDir = await createTempMonorepo() + const configPath = await writeConfigFile(rootDir, 'apps/documentation/devflare.config.ts', 'documentation') + + mockRenameWorkerApi('documentation', 'devflare-documentation') + + const logger = createLogger() + const result = await runRenameWorker(rootDir, 'documentation', 'devflare-documentation', logger) + + const updatedConfig = await readFile(configPath, 'utf-8') + + expect(result.exitCode).toBe(0) + expect(updatedConfig).toContain("name: 'devflare-documentation'") + expect(updatedConfig).not.toContain("name: 'documentation'") + expect(logger.messages.some((message) => message.args.join(' ').includes('Renamed remote Worker documentation โ†’ devflare-documentation'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('apps/documentation/devflare.config.ts'))).toBe(true) + }) + + test('renames the remote Worker when the matching nested config is already updated locally', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const rootDir = await createTempMonorepo() + const configPath = await writeConfigFile(rootDir, 'apps/documentation/devflare.config.ts', 'devflare-documentation') + + mockRenameWorkerApi('documentation', 'devflare-documentation') + + const logger = createLogger() + const result = await runRenameWorker(rootDir, 'documentation', 'devflare-documentation', logger) + + const updatedConfig = await readFile(configPath, 'utf-8') + + expect(result.exitCode).toBe(0) + expect(updatedConfig).toContain("name: 'devflare-documentation'") + expect(logger.messages.some((message) => message.args.join(' ').includes('Renamed remote Worker documentation โ†’ devflare-documentation'))).toBe(true) + expect(logger.messages.some((message) => message.args.join(' ').includes('already updated locally'))).toBe(true) + }) + + test('fails clearly when multiple nested configs match the old worker name', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const rootDir = await createTempMonorepo() + await writeConfigFile(rootDir, 'apps/docs-a/devflare.config.ts', 'documentation') + await writeConfigFile(rootDir, 'apps/docs-b/devflare.config.ts', 'documentation') + + const logger = createLogger() + const result = await runWorkerCommand( + { + command: 'worker', + args: ['rename', 'documentation'], + options: { + to: 'devflare-documentation' + } + }, + logger as any, + { cwd: rootDir } + ) + + expect(result.exitCode).toBe(1) + expect(logger.messages.some((message) => message.args.join(' ').includes('Multiple matching devflare configs were found'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/cli/workspace-build-guard.test.ts b/packages/devflare/tests/unit/cli/workspace-build-guard.test.ts new file mode 100644 index 0000000..0c1bb7d --- /dev/null +++ b/packages/devflare/tests/unit/cli/workspace-build-guard.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { + getLocalWorkspaceBuildGuardMessage, + getLocalWorkspaceBuildStatus +} from '../../../src/cli/workspace-build-guard' + +async function writeFixture(path: string, content: string, modifiedAt: Date): Promise { + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, content) + await utimes(path, modifiedAt, modifiedAt) +} + +describe('workspace build guard', () => { + test('reports missing dist exports for a local devflare workspace', async () => { + const packageRoot = await mkdtemp(join(tmpdir(), 'devflare-build-guard-missing-')) + + try { + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'devflare' + }, null, 2)) + await writeFixture(join(packageRoot, 'src', 'runtime.ts'), 'export const runtime = true', new Date('2026-04-16T10:00:00.000Z')) + + const status = await getLocalWorkspaceBuildStatus({ packageRoot }) + expect(status.state).toBe('missing-dist') + const message = await getLocalWorkspaceBuildGuardMessage('deploy', { + packageRoot, + env: {} + }) + expect(message).toContain('workspace exports are missing') + } finally { + await rm(packageRoot, { recursive: true, force: true }) + } + }) + + test('detects when source files are newer than dist exports', async () => { + const packageRoot = await mkdtemp(join(tmpdir(), 'devflare-build-guard-stale-')) + + try { + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'devflare' + }, null, 2)) + await writeFixture(join(packageRoot, 'src', 'runtime.ts'), 'export const runtime = true', new Date('2026-04-16T12:00:00.000Z')) + await writeFixture(join(packageRoot, 'dist', 'src', 'runtime.js'), 'export const runtime = true', new Date('2026-04-16T11:00:00.000Z')) + + const status = await getLocalWorkspaceBuildStatus({ packageRoot }) + expect(status.state).toBe('stale') + expect(status.sourceNewestAt?.toISOString()).toBe('2026-04-16T12:00:00.000Z') + expect(status.distNewestAt?.toISOString()).toBe('2026-04-16T11:00:00.000Z') + + const message = await getLocalWorkspaceBuildGuardMessage('types', { + packageRoot, + env: {} + }) + expect(message).toContain('workspace exports are stale') + } finally { + await rm(packageRoot, { recursive: true, force: true }) + } + }) + + test('treats a workspace as fresh when dist exports are newer than source', async () => { + const packageRoot = await mkdtemp(join(tmpdir(), 'devflare-build-guard-fresh-')) + + try { + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'devflare' + }, null, 2)) + await writeFixture(join(packageRoot, 'src', 'runtime.ts'), 'export const runtime = true', new Date('2026-04-16T11:00:00.000Z')) + await writeFixture(join(packageRoot, 'dist', 'src', 'runtime.js'), 'export const runtime = true', new Date('2026-04-16T12:00:00.000Z')) + + const status = await getLocalWorkspaceBuildStatus({ packageRoot }) + expect(status.state).toBe('fresh') + } finally { + await rm(packageRoot, { recursive: true, force: true }) + } + }) + + test('skips the local workspace guard in CI environments', async () => { + const packageRoot = await mkdtemp(join(tmpdir(), 'devflare-build-guard-ci-')) + + try { + await writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'devflare' + }, null, 2)) + await writeFixture(join(packageRoot, 'src', 'runtime.ts'), 'export const runtime = true', new Date('2026-04-16T12:00:00.000Z')) + + const message = await getLocalWorkspaceBuildGuardMessage('deploy', { + packageRoot, + env: { + CI: 'true' + } + }) + + expect(message).toBeUndefined() + } finally { + await rm(packageRoot, { recursive: true, force: true }) + } + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cli/wrangler-v4-compat.test.ts b/packages/devflare/tests/unit/cli/wrangler-v4-compat.test.ts new file mode 100644 index 0000000..11a7f2d --- /dev/null +++ b/packages/devflare/tests/unit/cli/wrangler-v4-compat.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const packageRoot = join(import.meta.dir, '..', '..', '..') +const repoRoot = join(packageRoot, '..', '..') +const thisFile = 'packages/devflare/tests/unit/cli/wrangler-v4-compat.test.ts' + +interface ScanFinding { + file: string + line: number + text: string + pattern: string +} + +function trackedTextFiles(): string[] { + const output = execFileSync('git', ['ls-files'], { + cwd: repoRoot, + encoding: 'utf8' + }) + + return output + .split(/\r?\n/) + .filter(Boolean) + .filter((file) => file !== thisFile) + .filter((file) => /\.(?:ts|tsx|js|mjs|cjs|json|jsonc|toml|ya?ml|md|sh)$/.test(file) || /(^|\/)Makefile$/.test(file)) +} + +function scan(patterns: Array<{ name: string; regex: RegExp }>): ScanFinding[] { + const findings: ScanFinding[] = [] + + for (const file of trackedTextFiles()) { + const content = readFileSync(join(repoRoot, file), 'utf8') + const lines = content.split(/\r?\n/) + + for (const [index, text] of lines.entries()) { + for (const pattern of patterns) { + if (pattern.regex.test(text)) { + findings.push({ + file, + line: index + 1, + text: text.trim(), + pattern: pattern.name + }) + } + pattern.regex.lastIndex = 0 + } + } + } + + return findings +} + +describe('Wrangler v4 compatibility audit', () => { + test('does not use commands or config removed in Wrangler v4', () => { + const findings = scan([ + { name: 'wrangler publish', regex: /\bwrangler\s+publish\b/ }, + { name: 'wrangler generate', regex: /\bwrangler\s+generate\b/ }, + { name: 'wrangler pages publish', regex: /\bwrangler\s+pages\s+publish\b/ }, + { name: 'wrangler version', regex: /\bwrangler\s+version\b(?!s)/ }, + { name: 'getBindingsProxy()', regex: /\bgetBindingsProxy\s*\(/ }, + { name: 'legacy_assets', regex: /\blegacy_assets\b/ }, + { name: 'node_compat', regex: /\bnode_compat\b/ }, + { name: 'usage_model', regex: /\busage_model\b/ }, + { name: '--legacy-assets', regex: /--legacy-assets\b/ }, + { name: '--node-compat', regex: /--node-compat\b/ } + ]) + + expect(findings).toEqual([]) + }) + + test('does not rely on Wrangler v3 remote defaults for KV or R2 object commands', () => { + const findings = scan([ + { name: 'wrangler kv key without mode', regex: /\bwrangler\s+kv\s+key\s+(?:get|put|delete|list)\b(?!.*--(?:local|remote)\b)/ }, + { name: 'wrangler kv bulk without mode', regex: /\bwrangler\s+kv\s+bulk\s+(?:put|delete)\b(?!.*--(?:local|remote)\b)/ }, + { name: 'wrangler r2 object without mode', regex: /\bwrangler\s+r2\s+object\s+(?:get|put|delete)\b(?!.*--(?:local|remote)\b)/ } + ]) + + expect(findings).toEqual([]) + }) + + test('keeps the package Node engine compatible with Wrangler v4', () => { + const packageJson = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8') + ) as { engines?: Record } + + expect(packageJson.engines?.node).toBe('>=20') + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/account-resources.test.ts b/packages/devflare/tests/unit/cloudflare/account-resources.test.ts new file mode 100644 index 0000000..ed2182d --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/account-resources.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { listVectorizeIndexes } from '../../../src/cloudflare/account-resources' +import { CloudflareAPIError } from '../../../src/cloudflare/api' + +const originalFetch = globalThis.fetch +const originalToken = process.env.CLOUDFLARE_API_TOKEN + +afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } +}) + +describe('listVectorizeIndexes', () => { + test('returns [] when the endpoint is unavailable on this account (404)', async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 7000, message: 'No route for that URI' }], + messages: [], + result: null + }), { status: 404, headers: { 'Content-Type': 'application/json' } }) + }) as unknown as typeof fetch + + const indexes = await listVectorizeIndexes('acct', { token: 'cf_test_token' }) + expect(indexes).toEqual([]) + }) + + test('re-throws permission errors (403) instead of silently returning []', async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 9109, message: 'Unauthorized to access this resource.' }], + messages: [], + result: null + }), { status: 403, headers: { 'Content-Type': 'application/json' } }) + }) as unknown as typeof fetch + + await expect(listVectorizeIndexes('acct', { token: 'cf_test_token' })) + .rejects.toBeInstanceOf(CloudflareAPIError) + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/account-status.test.ts b/packages/devflare/tests/unit/cloudflare/account-status.test.ts new file mode 100644 index 0000000..219ac18 --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/account-status.test.ts @@ -0,0 +1,58 @@ +import { afterEach, expect, mock, test } from 'bun:test' +import { getServiceStatus } from '../../../src/cloudflare/account-status' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { installTrackedTimeouts } from '../../helpers/tracked-timeouts' + +const originalFetch = globalThis.fetch +const originalSetTimeout = globalThis.setTimeout +const originalClearTimeout = globalThis.clearTimeout +const originalToken = process.env.CLOUDFLARE_API_TOKEN + +afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + if (originalToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalToken + } +}) + +test('getServiceStatus clears timeout guards after a successful inventory lookup', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const { scheduledTimeoutIds, clearedTimeoutIds } = installTrackedTimeouts() + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (!url.includes('/accounts/acc_123/workers/scripts?page=1&per_page=50')) { + throw new Error(`Unexpected fetch URL: ${url}`) + } + + return jsonResponse([ + { + id: 'worker-1', + name: 'worker-1', + created_on: '2026-04-12T00:00:00.000Z', + modified_on: '2026-04-12T00:00:00.000Z' + } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) + }) as unknown as typeof fetch + + const status = await getServiceStatus('acc_123', 'workers') + + expect(status).toEqual({ + service: 'workers', + available: true, + count: 1 + }) + expect([...clearedTimeoutIds].sort((left, right) => left - right)).toEqual( + [...scheduledTimeoutIds].sort((left, right) => left - right) + ) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cloudflare/account-workers.test.ts b/packages/devflare/tests/unit/cloudflare/account-workers.test.ts new file mode 100644 index 0000000..ab2d457 --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/account-workers.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { + getWorkerVersionDetail, + listWorkerVersions +} from '../../../src/cloudflare/account-workers' +import { jsonResponse } from '../../helpers/cloudflare-api' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('account-workers version metadata parsing', () => { + test('listWorkerVersions preserves Cloudflare has_preview metadata', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (!url.includes('/accounts/acc_123/workers/scripts/demo-worker/versions?page=1&per_page=100')) { + throw new Error(`Unexpected fetch URL: ${url}`) + } + + return jsonResponse({ + items: [ + { + id: 'version-preview', + number: 2, + metadata: { + author_id: 'user_123', + created_on: '2026-04-16T00:00:00.000Z', + modified_on: '2026-04-16T00:00:01.000Z', + has_preview: true, + source: 'wrangler' + } + } + ] + }) + }) as unknown as typeof fetch + + const versions = await listWorkerVersions('acc_123', 'demo-worker', { + token: 'cf_test_token' + }) + + expect(versions).toHaveLength(1) + expect(versions[0].id).toBe('version-preview') + expect(versions[0].metadata.hasPreview).toBe(true) + }) + + test('getWorkerVersionDetail preserves Cloudflare has_preview metadata', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (!url.includes('/accounts/acc_123/workers/scripts/demo-worker/versions/version-preview')) { + throw new Error(`Unexpected fetch URL: ${url}`) + } + + return jsonResponse({ + id: 'version-preview', + number: 2, + metadata: { + author_id: 'user_123', + created_on: '2026-04-16T00:00:00.000Z', + modified_on: '2026-04-16T00:00:01.000Z', + has_preview: true, + source: 'wrangler' + } + }) + }) as unknown as typeof fetch + + const version = await getWorkerVersionDetail('acc_123', 'demo-worker', 'version-preview', { + token: 'cf_test_token' + }) + + expect(version.id).toBe('version-preview') + expect(version.metadata.hasPreview).toBe(true) + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cloudflare/api.test.ts b/packages/devflare/tests/unit/cloudflare/api.test.ts new file mode 100644 index 0000000..a6ca0ab --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/api.test.ts @@ -0,0 +1,293 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { apiGet, apiGetAll, CloudflareAPIError, kvDelete } from '../../../src/cloudflare/api' +import { jsonResponse } from '../../helpers/cloudflare-api' +import { installTrackedTimeouts } from '../../helpers/tracked-timeouts' + +const originalFetch = globalThis.fetch +const originalSetTimeout = globalThis.setTimeout +const originalClearTimeout = globalThis.clearTimeout +const originalCloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN + +afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + + if (originalCloudflareApiToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalCloudflareApiToken + } +}) + +describe('apiGetAll', () => { + test('collects paginated array results', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.endsWith('/items?page=1&per_page=50')) { + return jsonResponse([ + { id: 'one' }, + { id: 'two' } + ], { + page: 1, + per_page: 50, + total_pages: 2, + count: 2, + total_count: 3 + }) + } + + if (url.endsWith('/items?page=2&per_page=50')) { + return jsonResponse([ + { id: 'three' } + ], { + page: 2, + per_page: 50, + total_pages: 2, + count: 1, + total_count: 3 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const result = await apiGetAll<{ id: string }>('/items', { token: 'cf_test_token' }) + + expect(result).toEqual([ + { id: 'one' }, + { id: 'two' }, + { id: 'three' } + ]) + }) + + test('accepts object-wrapped array results like the R2 bucket list API', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (!url.endsWith('/accounts/test-account/r2/buckets?page=1&per_page=50')) { + throw new Error(`Unexpected fetch URL: ${url}`) + } + + return jsonResponse({ + buckets: [ + { name: 'preview-assets', creation_date: '2026-04-11T00:00:00.000Z' }, + { name: 'preview-archive', creation_date: '2026-04-11T00:00:00.000Z' } + ] + }) + }) as unknown as typeof fetch + + const result = await apiGetAll<{ name: string; creation_date: string }>('/accounts/test-account/r2/buckets', { + token: 'cf_test_token' + }) + + expect(result.map((bucket) => bucket.name)).toEqual(['preview-assets', 'preview-archive']) + }) + + test('follows cursor pagination when Cloudflare returns a next cursor', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.endsWith('/accounts/test-account/r2/buckets?page=1&per_page=50')) { + return jsonResponse({ + buckets: [ + { name: 'preview-assets', creation_date: '2026-04-11T00:00:00.000Z' } + ] + }, { + cursor: 'next-page', + per_page: 50 + }) + } + + if (url.endsWith('/accounts/test-account/r2/buckets?cursor=next-page&per_page=50')) { + return jsonResponse({ + buckets: [ + { name: 'preview-archive', creation_date: '2026-04-11T00:00:00.000Z' } + ] + }, { + per_page: 50 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const result = await apiGetAll<{ name: string; creation_date: string }>('/accounts/test-account/r2/buckets', { + token: 'cf_test_token' + }) + + expect(result.map((bucket) => bucket.name)).toEqual(['preview-assets', 'preview-archive']) + }) + + test('clears timeout guards after a successful request', async () => { + const { scheduledTimeoutIds, clearedTimeoutIds } = installTrackedTimeouts() + + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + if (!url.endsWith('/items?page=1&per_page=50')) { + throw new Error(`Unexpected fetch URL: ${url}`) + } + + return jsonResponse([ + { id: 'one' } + ], { + page: 1, + per_page: 50, + total_pages: 1, + count: 1, + total_count: 1 + }) + }) as unknown as typeof fetch + + const result = await apiGetAll<{ id: string }>('/items', { token: 'cf_test_token' }) + + expect(result).toEqual([{ id: 'one' }]) + expect(clearedTimeoutIds).toEqual(scheduledTimeoutIds) + }) + + test('retries authentication failures independently per in-flight request', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const firstAttemptResolvers: Array<() => void> = [] + let fetchCalls = 0 + + globalThis.fetch = mock(async () => { + fetchCalls += 1 + + if (fetchCalls <= 2) { + await new Promise((resolve) => { + firstAttemptResolvers.push(resolve) + + if (firstAttemptResolvers.length === 2) { + for (const release of firstAttemptResolvers.splice(0)) { + release() + } + } + }) + } + + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 10000, message: 'authentication error' }], + messages: [], + result: null + }), { + status: 401, + headers: { + 'Content-Type': 'application/json' + } + }) + }) as unknown as typeof fetch + + const results = await Promise.allSettled([ + apiGet('/items'), + apiGet('/items') + ]) + + expect(fetchCalls).toBe(4) + for (const result of results) { + expect(result.status).toBe('rejected') + if (result.status === 'rejected') { + expect(result.reason).toBeInstanceOf(CloudflareAPIError) + expect(result.reason.code).toBe(401) + } + } + }) + + test('throws a typed CloudflareAPIError when the API returns invalid JSON', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + + globalThis.fetch = mock(async () => new Response('bad gateway', { + status: 502, + headers: { + 'Content-Type': 'text/html' + } + })) as unknown as typeof fetch + + await expect(apiGet('/items')).rejects.toMatchObject({ + name: 'CloudflareAPIError', + message: expect.stringContaining('Cloudflare API returned an invalid JSON response.'), + code: 502 + }) + }) + + test('throws a clear error when a JSON response is not the v4 envelope shape', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + + globalThis.fetch = mock(async () => new Response(JSON.stringify({ data: [1, 2, 3] }), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + })) as unknown as typeof fetch + + await expect(apiGet('/items')).rejects.toMatchObject({ + name: 'CloudflareAPIError', + message: expect.stringContaining('Cloudflare GET /items returned a non-envelope JSON response.'), + code: 200 + }) + }) + + test('surfaces the first envelope error code and message when success is false', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + + globalThis.fetch = mock(async () => new Response(JSON.stringify({ + success: false, + errors: [ + { code: 7003, message: 'Could not route to the requested resource' }, + { code: 7000, message: 'ignored secondary error' } + ], + messages: [], + result: null + }), { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + })) as unknown as typeof fetch + + const failure = await apiGet('/items').catch((error) => error) as CloudflareAPIError + + expect(failure).toBeInstanceOf(CloudflareAPIError) + expect(failure.message).toContain('7003') + expect(failure.message).toContain('Could not route to the requested resource') + expect(failure.code).toBe(400) + expect(failure.errors[0]).toEqual({ code: 7003, message: 'Could not route to the requested resource' }) + }) +}) + +describe('kvDelete', () => { + test('issues DELETE to the KV values endpoint and resolves on success envelope', async () => { + const calls: Array<{ url: string; method: string; authorization: string | null }> = [] + + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const headers = new Headers(init?.headers) + calls.push({ + url, + method: String(init?.method ?? 'GET'), + authorization: headers.get('authorization') + }) + + return new Response(JSON.stringify({ + success: true, + errors: [], + messages: [], + result: null + }), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }) + }) as unknown as typeof fetch + + await expect( + kvDelete('acct-123', 'ns-abc', 'settings:defaultAccountId', { token: 'cf_test_token' }) + ).resolves.toBeUndefined() + + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe('DELETE') + expect(calls[0].url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-123/storage/kv/namespaces/ns-abc/values/settings%3AdefaultAccountId') + expect(calls[0].authorization).toBe('Bearer cf_test_token') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cloudflare/known-permission-group-ids.test.ts b/packages/devflare/tests/unit/cloudflare/known-permission-group-ids.test.ts new file mode 100644 index 0000000..90d6d2f --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/known-permission-group-ids.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' +import { + renderGeneratedFile, + resolveUpdatedEntries +} from '../../../scripts/refresh-permission-groups' +import { KNOWN_PERMISSION_GROUP_DISPLAY_NAMES } from '../../../src/cloudflare/tokens' +import { KNOWN_PERMISSION_GROUP_IDS_DATA } from '../../../src/cloudflare/known-permission-group-ids.generated' + +describe('known-permission-group-ids.generated.ts', () => { + test('exports an entry for every symbolic permission name used by tokens.ts', () => { + const symbolicNames = Object.keys(KNOWN_PERMISSION_GROUP_DISPLAY_NAMES).sort() + const generatedKeys = Object.keys(KNOWN_PERMISSION_GROUP_IDS_DATA).sort() + + expect(generatedKeys).toEqual(symbolicNames) + }) + + test('every value is either a non-empty string or null (no undefined / no empty strings)', () => { + for (const [key, value] of Object.entries(KNOWN_PERMISSION_GROUP_IDS_DATA)) { + if (value === null) { + continue + } + + expect(typeof value).toBe('string') + expect((value as string).length).toBeGreaterThan(0) + // UUID-ish sanity: must not contain whitespace or commentary if non-null + expect(/\s/.test(value as string)).toBe(false) + expect(key).toBeTruthy() + } + }) +}) + +describe('resolveUpdatedEntries', () => { + test('matches each symbolic name to the API permission group with the same display name', () => { + const apiResponse = [ + { id: 'uuid-workers-scripts-write', name: 'Workers Scripts Write' }, + { id: 'uuid-workers-scripts-read', name: 'Workers Scripts Read' }, + { id: 'uuid-account-settings-read', name: 'Account Settings Read' }, + { id: 'uuid-workers-kv-write', name: 'Workers KV Storage Write' }, + { id: 'uuid-workers-kv-read', name: 'Workers KV Storage Read' }, + { id: 'uuid-account-api-tokens-write', name: 'Account API Tokens Write' }, + { id: 'uuid-account-api-tokens-read', name: 'Account API Tokens Read' }, + { id: 'uuid-noise', name: 'Some Other Permission' } + ] + + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + + const resolvedById = Object.fromEntries( + entries.map((entry) => [entry.symbolicName, entry.resolvedId]) + ) + + expect(resolvedById).toEqual({ + WORKERS_SCRIPTS_WRITE: 'uuid-workers-scripts-write', + WORKERS_SCRIPTS_READ: 'uuid-workers-scripts-read', + ACCOUNT_SETTINGS_READ: 'uuid-account-settings-read', + WORKERS_KV_STORAGE_WRITE: 'uuid-workers-kv-write', + WORKERS_KV_STORAGE_READ: 'uuid-workers-kv-read', + ACCOUNT_API_TOKENS_WRITE: 'uuid-account-api-tokens-write', + ACCOUNT_API_TOKENS_READ: 'uuid-account-api-tokens-read' + }) + }) + + test('falls back to null when an entry is missing from the API response', () => { + const apiResponse = [ + { id: 'uuid-workers-scripts-write', name: 'Workers Scripts Write' } + ] + + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + const missingEntry = entries.find((entry) => entry.symbolicName === 'WORKERS_SCRIPTS_READ') + + expect(missingEntry?.resolvedId).toBeNull() + }) + + test('keeps the previous id for missing entries when keepExisting is true and a value is already present', () => { + // We can only assert this property generically because the current + // generated data file may legitimately ship with all-null values. + // The unit under test is the merge logic: a missing API entry with a + // previously-known id must not be cleared when keepExisting is true. + const apiResponse: Array<{ id: string; name: string }> = [] + + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: true }) + + for (const entry of entries) { + expect(entry.resolvedId).toBe(entry.previousId) + } + }) + + test('clears entries missing from the API response when keepExisting is false', () => { + const apiResponse: Array<{ id: string; name: string }> = [] + + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + + for (const entry of entries) { + expect(entry.resolvedId).toBeNull() + } + }) + + test('ignores duplicate display names and keeps the first match (account-scoped wins by listing order)', () => { + const apiResponse = [ + { id: 'uuid-account-scope', name: 'Workers Scripts Write' }, + { id: 'uuid-zone-scope-duplicate', name: 'Workers Scripts Write' } + ] + + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + const writeEntry = entries.find((entry) => entry.symbolicName === 'WORKERS_SCRIPTS_WRITE') + + expect(writeEntry?.resolvedId).toBe('uuid-account-scope') + }) +}) + +describe('renderGeneratedFile', () => { + test('emits a deterministic, importable TypeScript module with the AUTO-GENERATED banner', () => { + const apiResponse = [ + { id: 'uuid-workers-scripts-write', name: 'Workers Scripts Write' } + ] + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + + const rendered = renderGeneratedFile(entries) + + expect(rendered).toContain('AUTO-GENERATED FILE') + expect(rendered).toContain('export const KNOWN_PERMISSION_GROUP_IDS_DATA') + expect(rendered).toContain("WORKERS_SCRIPTS_WRITE: 'uuid-workers-scripts-write'") + expect(rendered).toContain('WORKERS_SCRIPTS_READ: null') + // File ends with a single trailing newline + expect(rendered.endsWith('\n')).toBe(true) + expect(rendered.endsWith('\n\n')).toBe(false) + }) + + test('produces stable output for the same input (idempotent across calls)', () => { + const apiResponse = [ + { id: 'uuid-workers-scripts-write', name: 'Workers Scripts Write' } + ] + const entries = resolveUpdatedEntries(apiResponse, { keepExisting: false }) + + const first = renderGeneratedFile(entries) + const second = renderGeneratedFile(entries) + + expect(first).toBe(second) + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/kv-namespace.test.ts b/packages/devflare/tests/unit/cloudflare/kv-namespace.test.ts new file mode 100644 index 0000000..daa4fde --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/kv-namespace.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { getOrCreateNamedKVNamespace } from '../../../src/cloudflare/kv-namespace' +import { jsonResponse } from '../../helpers/cloudflare-api' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('getOrCreateNamedKVNamespace', () => { + test('reuses an existing namespace found on a later page', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (init?.method === 'GET' && url.endsWith('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([ + { id: 'ns-other', title: 'other-namespace' } + ], { + page: 1, + per_page: 50, + total_pages: 2, + count: 1, + total_count: 2 + }) + } + + if (init?.method === 'GET' && url.endsWith('/accounts/acc_123/storage/kv/namespaces?page=2&per_page=50')) { + return jsonResponse([ + { id: 'ns-devflare', title: 'devflare-usage' } + ], { + page: 2, + per_page: 50, + total_pages: 2, + count: 1, + total_count: 2 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const namespaceId = await getOrCreateNamedKVNamespace('acc_123', undefined, { + token: 'cf_test_token' + }) + + expect(namespaceId).toBe('ns-devflare') + }) + + test('creates the namespace when no existing match is found', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (init?.method === 'GET' && url.endsWith('/accounts/acc_123/storage/kv/namespaces?page=1&per_page=50')) { + return jsonResponse([], { + page: 1, + per_page: 50, + total_pages: 1, + count: 0, + total_count: 0 + }) + } + + if (init?.method === 'POST' && url.endsWith('/accounts/acc_123/storage/kv/namespaces')) { + return jsonResponse({ + id: 'ns-created', + title: 'custom-title' + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const namespaceId = await getOrCreateNamedKVNamespace('acc_123', 'custom-title', { + token: 'cf_test_token' + }) + + expect(namespaceId).toBe('ns-created') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/cloudflare/preferences.test.ts b/packages/devflare/tests/unit/cloudflare/preferences.test.ts new file mode 100644 index 0000000..7659eee --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/preferences.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, readFileSync, readdirSync, rmSync, existsSync, mkdirSync, chmodSync } from 'node:fs' +import { tmpdir, platform } from 'node:os' +import { join } from 'node:path' +import { writeFileAtomic } from '../../../src/cloudflare/preferences' + +describe('writeFileAtomic', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devflare-prefs-')) + }) + + afterEach(() => { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('writes the final file with exact given contents', () => { + const target = join(dir, 'out.json') + const contents = '{\n\t"hello": "world"\n}\n' + + writeFileAtomic(target, contents) + + expect(existsSync(target)).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe(contents) + }) + + test('does not leave a .tmp-* sibling after success', () => { + const target = join(dir, 'out.json') + writeFileAtomic(target, 'payload') + + const leftovers = readdirSync(dir).filter((name) => name.startsWith('out.json.tmp-')) + expect(leftovers).toEqual([]) + }) + + test('overwrites an existing file atomically', () => { + const target = join(dir, 'out.json') + writeFileAtomic(target, 'first') + writeFileAtomic(target, 'second') + + expect(readFileSync(target, 'utf-8')).toBe('second') + const leftovers = readdirSync(dir).filter((name) => name.startsWith('out.json.tmp-')) + expect(leftovers).toEqual([]) + }) + + test('throws through and cleans up temp when the target path is unwritable', () => { + // Point the target at a path whose parent does not exist so writeFileSync throws. + // This exercises the throw-through path without leaving temp files behind, + // since the temp file is never created. + const target = join(dir, 'nope', 'out.json') + + expect(() => writeFileAtomic(target, 'payload')).toThrow() + + const leftovers = readdirSync(dir).filter((name) => name.includes('.tmp-')) + expect(leftovers).toEqual([]) + }) + + // Skip on Windows where chmod-based read-only semantics do not apply cleanly. + test.skipIf(platform() === 'win32')('cleans up temp file when rename fails', () => { + const target = join(dir, 'readonly-subdir', 'out.json') + mkdirSync(join(dir, 'readonly-subdir')) + // Create target as a directory so renameSync onto it fails. + mkdirSync(target) + + expect(() => writeFileAtomic(target, 'payload')).toThrow() + + const leftovers = readdirSync(join(dir, 'readonly-subdir')).filter((name) => name.includes('.tmp-')) + expect(leftovers).toEqual([]) + + // cleanup + chmodSync(join(dir, 'readonly-subdir'), 0o755) + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/preview-registry-inference.test.ts b/packages/devflare/tests/unit/cloudflare/preview-registry-inference.test.ts new file mode 100644 index 0000000..11eaf48 --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/preview-registry-inference.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + getExplicitPreviewSyncOverrides, + inferRecordSource +} from '../../../src/cloudflare/preview-registry-inference' + +describe('inferRecordSource', () => { + const originalGithubActions = process.env.GITHUB_ACTIONS + + beforeEach(() => { + delete process.env.GITHUB_ACTIONS + }) + + afterEach(() => { + if (originalGithubActions === undefined) { + delete process.env.GITHUB_ACTIONS + } else { + process.env.GITHUB_ACTIONS = originalGithubActions + } + }) + + test('prefers the explicit source over the fallback', () => { + expect(inferRecordSource('dashboard', 'wrangler')).toBe('dashboard') + }) + + test('maps wrangler fallback to github-action when GITHUB_ACTIONS is set', () => { + process.env.GITHUB_ACTIONS = 'true' + expect(inferRecordSource(undefined, 'wrangler')).toBe('github-action') + }) + + test('maps wrangler fallback to cli when not in GitHub Actions', () => { + expect(inferRecordSource(undefined, 'wrangler')).toBe('cli') + }) + + test('maps known fallbacks verbatim and defaults to unknown otherwise', () => { + expect(inferRecordSource(undefined, 'dashboard')).toBe('dashboard') + expect(inferRecordSource(undefined, 'workers-builds')).toBe('workers-builds') + expect(inferRecordSource(undefined, 'mystery')).toBe('unknown') + expect(inferRecordSource(undefined, undefined)).toBe('unknown') + }) +}) + +describe('getExplicitPreviewSyncOverrides', () => { + test('returns an empty object when the version does not match', () => { + const result = getExplicitPreviewSyncOverrides( + { + accountId: 'acc_1', + workerName: 'worker', + versionId: 'v-1', + previewScope: 'pr-1', + previewUrl: 'https://example.com', + branchName: 'main', + commitSha: 'abc' + }, + 'v-other' + ) + + expect(result).toEqual({}) + }) + + test('propagates explicit overrides when the version matches', () => { + const result = getExplicitPreviewSyncOverrides( + { + accountId: 'acc_1', + workerName: 'worker', + versionId: 'v-1', + previewScope: 'pr-1', + previewUrl: 'https://example.com/preview', + previewScopeUrl: 'https://example.com/scope', + branchName: 'main', + commitSha: 'abc123' + }, + 'v-1' + ) + + expect(result).toEqual({ + previewScope: 'pr-1', + previewUrl: 'https://example.com/preview', + previewScopeUrl: 'https://example.com/scope', + branchName: 'main', + commitSha: 'abc123' + }) + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/preview-registry.test.ts b/packages/devflare/tests/unit/cloudflare/preview-registry.test.ts new file mode 100644 index 0000000..0ded16b --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/preview-registry.test.ts @@ -0,0 +1,558 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { + getPreviewRegistryContext, + reconcilePreviewRegistry, + retirePreviewRegistry +} from '../../../src/cloudflare/preview-registry' +import { clearPreviewRegistrySchemaCache } from '../../../src/cloudflare/preview-registry-store' +import { createTrackedTempDirectories } from '../../helpers/tracked-temp-directories' +import { + capturePreviewTestEnvironmentSnapshot, + createD1ResultsResponse, + createDeploymentRecordFixture, + createPreviewRecordFixture, + createPreviewScopeRecordFixture, + createRegistryDatabaseListResponse, + createRegistryDatabaseRecord, + createSerializedRegistryRecord, + jsonResponse, + restorePreviewTestEnvironmentSnapshot +} from '../cli/previews.test-utils' + +const originalEnvironment = capturePreviewTestEnvironmentSnapshot() +const temporaryCacheDirectories = createTrackedTempDirectories() +const defaultReconcileRequest = { + accountId: 'acc_123', + workerName: 'demo-worker', + versionId: '5dba9570-33c4-4375-b784-e1b34ad01569', + previewScope: 'feature-branch', + previewUrl: 'https://5dba9570-demo-worker.example-subdomain.workers.dev', + previewScopeUrl: 'https://feature-branch-demo-worker.example-subdomain.workers.dev', + branchName: 'feature/branch', + commitSha: 'abcdef1234567', + source: 'cli' as const +} + +function createPreviewRegistryFetch( + options: { + recordedSql?: string[] + versionsItems?: Array> + versionDetail?: Record + deployments?: Array> + previewRecords?: Array> + previewScopeRecords?: Array> + deploymentRecords?: Array> + recordedStatements?: Array<{ sql: string; params: unknown[] }> + tableColumnsByTable?: Record + } = {} +): typeof fetch { + return mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + return createRegistryDatabaseListResponse([createRegistryDatabaseRecord({ fileSize: 4096 })]) + } + + if (url.endsWith('/accounts/acc_123/workers/subdomain')) { + return jsonResponse({ + subdomain: 'example-subdomain' + }) + } + + if ( + url.includes('/accounts/acc_123/workers/scripts/demo-worker/versions?page=1&per_page=100') + ) { + return jsonResponse({ + items: options.versionsItems ?? [] + }) + } + + if ( + url.endsWith( + '/accounts/acc_123/workers/scripts/demo-worker/versions/5dba9570-33c4-4375-b784-e1b34ad01569' + ) + ) { + if (options.versionDetail) { + return jsonResponse(options.versionDetail) + } + } + + if (url.endsWith('/accounts/acc_123/workers/scripts/demo-worker/deployments')) { + return jsonResponse({ + deployments: options.deployments ?? [] + }) + } + + if (url.endsWith('/accounts/acc_123/d1/database/db_123/query')) { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : {} + const sql = String(body.sql ?? '') + options.recordedSql?.push(sql) + if (options.recordedStatements) { + options.recordedStatements.push({ + sql, + params: Array.isArray(body.params) ? body.params : [] + }) + } + + const pragmaMatch = sql.match(/^PRAGMA table_info\("([^"]+)"\)$/) + if (pragmaMatch) { + const tableName = pragmaMatch[1] + const columns = options.tableColumnsByTable?.[tableName] + if (columns) { + return createD1ResultsResponse(columns.map((name) => ({ name }))) + } + } + + if (sql.startsWith('SELECT payload_json FROM devflare_preview_records')) { + return createD1ResultsResponse( + (options.previewRecords ?? []).map(createSerializedRegistryRecord) + ) + } + + if (sql.startsWith('SELECT payload_json FROM devflare_preview_scope_records')) { + return createD1ResultsResponse( + (options.previewScopeRecords ?? []).map(createSerializedRegistryRecord) + ) + } + + if (sql.startsWith('SELECT payload_json FROM devflare_deployment_records')) { + return createD1ResultsResponse( + (options.deploymentRecords ?? []).map(createSerializedRegistryRecord) + ) + } + + return createD1ResultsResponse() + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch +} + +function expectRegistryInsertStatements(recordedSql: string[]): void { + expect(recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_preview_records'))).toBe( + true + ) + expect( + recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_preview_scope_records')) + ).toBe(true) + expect(recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_deployment_records'))).toBe( + true + ) +} + +function toLegacyAliasRecord(record: Record): Record { + return { + ...Object.fromEntries( + Object.entries(record).filter(([key]) => key !== 'scope' && key !== 'scopeUrl') + ), + alias: defaultReconcileRequest.previewScope, + aliasPreviewUrl: defaultReconcileRequest.previewScopeUrl + } +} + +afterEach(() => { + clearPreviewRegistrySchemaCache('db_123') + restorePreviewTestEnvironmentSnapshot(originalEnvironment) + temporaryCacheDirectories.cleanup() +}) + +describe('preview registry', () => { + test('caches registry discovery locally to avoid repeated D1 listing', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + process.env.DEVFLARE_CACHE_DIR = temporaryCacheDirectories.create('devflare-preview-registry-') + let databaseListRequests = 0 + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input) + + if (url.includes('/accounts/acc_123/d1/database?page=1&per_page=50')) { + databaseListRequests += 1 + return createRegistryDatabaseListResponse([ + createRegistryDatabaseRecord({ fileSize: 4096 }) + ]) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) as unknown as typeof fetch + + const first = await getPreviewRegistryContext({ + accountId: 'acc_123' + }) + const second = await getPreviewRegistryContext({ + accountId: 'acc_123' + }) + + expect(first?.databaseId).toBe('db_123') + expect(second?.databaseId).toBe('db_123') + expect(databaseListRequests).toBe(1) + }) + + test('reconciles live preview and deployment records into the registry', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const recordedSql: string[] = [] + globalThis.fetch = createPreviewRegistryFetch({ + recordedSql, + versionsItems: [ + { + id: defaultReconcileRequest.versionId, + number: 7, + metadata: { + author_id: 'user_123', + created_on: '2025-01-01T00:00:00.000Z', + modified_on: '2025-01-01T00:00:00.000Z', + hasPreview: true, + source: 'wrangler' + } + } + ], + deployments: [ + { + id: 'deployment_123', + created_on: '2025-01-02T00:00:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: defaultReconcileRequest.versionId + } + ], + annotations: { + 'workers/message': 'Deploy preview branch', + 'workers/triggered_by': 'upload' + }, + author_email: 'dev@example.com' + } + ] + }) + + const result = await reconcilePreviewRegistry(defaultReconcileRequest) + + expect(result.registry.databaseName).toBe('devflare-registry') + expect(result.previews).toHaveLength(1) + expect(result.previewScopes).toHaveLength(1) + expect(result.deployments).toHaveLength(2) + expect(result.previews[0].scope).toBe('feature-branch') + expect(result.previewScopes[0].scopeUrl).toBe( + 'https://feature-branch-demo-worker.example-subdomain.workers.dev' + ) + expect(result.deployments.some((record) => record.channel === 'preview')).toBe(true) + expect(result.deployments.some((record) => record.channel === 'production')).toBe(true) + expectRegistryInsertStatements(recordedSql) + }) + + test('records the freshly uploaded preview even when listWorkerVersions does not surface it yet', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const recordedSql: string[] = [] + globalThis.fetch = createPreviewRegistryFetch({ + recordedSql, + versionsItems: [], + versionDetail: { + id: defaultReconcileRequest.versionId, + number: 7, + metadata: { + author_id: 'user_123', + created_on: '2025-01-01T00:00:00.000Z', + modified_on: '2025-01-01T00:00:00.000Z', + hasPreview: false, + source: 'wrangler' + } + }, + deployments: [] + }) + + const result = await reconcilePreviewRegistry(defaultReconcileRequest) + + expect(result.previews).toHaveLength(1) + expect(result.previewScopes).toHaveLength(1) + expect(result.deployments).toHaveLength(1) + expect(result.previews[0].versionId).toBe('5dba9570-33c4-4375-b784-e1b34ad01569') + expect(result.previews[0].previewUrl).toBe( + 'https://5dba9570-demo-worker.example-subdomain.workers.dev' + ) + expectRegistryInsertStatements(recordedSql) + }) + + test('preserves locally tracked previews when Cloudflare cannot enumerate them during reconcile', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const recordedSql: string[] = [] + globalThis.fetch = createPreviewRegistryFetch({ + recordedSql, + versionsItems: [], + deployments: [], + previewRecords: [ + createPreviewRecordFixture({ + workerName: 'demo-worker', + versionId: defaultReconcileRequest.versionId, + previewUrl: defaultReconcileRequest.previewUrl, + scope: defaultReconcileRequest.previewScope, + scopeUrl: defaultReconcileRequest.previewScopeUrl + }) + ], + previewScopeRecords: [ + createPreviewScopeRecordFixture({ + workerName: 'demo-worker', + scope: defaultReconcileRequest.previewScope, + scopeUrl: defaultReconcileRequest.previewScopeUrl, + versionId: defaultReconcileRequest.versionId + }) + ], + deploymentRecords: [ + createDeploymentRecordFixture({ + id: 'deployment:demo-worker:preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + workerName: 'demo-worker', + deploymentId: 'preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + channel: 'preview', + versionId: defaultReconcileRequest.versionId, + previewId: 'preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + environment: 'preview', + url: defaultReconcileRequest.previewScopeUrl + }) + ] + }) + + const result = await reconcilePreviewRegistry({ + accountId: 'acc_123', + workerName: 'demo-worker' + }) + + expect(result.previews).toHaveLength(0) + expect(result.previewScopes).toHaveLength(0) + expect(result.deployments).toHaveLength(0) + expect(recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_preview_records'))).toBe( + false + ) + expect( + recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_preview_scope_records')) + ).toBe(false) + expect( + recordedSql.some((sql) => sql.startsWith('INSERT INTO devflare_deployment_records')) + ).toBe(false) + }) + + test('normalizes legacy alias fields from stored registry payloads', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const previewRecord = createPreviewRecordFixture({ + workerName: 'demo-worker', + versionId: defaultReconcileRequest.versionId, + previewUrl: defaultReconcileRequest.previewUrl, + branchName: defaultReconcileRequest.branchName + }) + const previewScopeRecord = createPreviewScopeRecordFixture({ + workerName: 'demo-worker', + scope: defaultReconcileRequest.previewScope, + scopeUrl: defaultReconcileRequest.previewScopeUrl, + versionId: defaultReconcileRequest.versionId, + branchName: defaultReconcileRequest.branchName + }) + + globalThis.fetch = createPreviewRegistryFetch({ + versionsItems: [], + deployments: [], + previewRecords: [toLegacyAliasRecord(previewRecord)], + previewScopeRecords: [toLegacyAliasRecord(previewScopeRecord)] + }) + + const result = await reconcilePreviewRegistry({ + accountId: 'acc_123', + workerName: 'demo-worker' + }) + + expect(result.previews).toHaveLength(0) + expect(result.previewScopes).toHaveLength(0) + expect(result.deployments).toHaveLength(0) + }) + + test('migrates missing preview registry columns before writing new records', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const recordedSql: string[] = [] + globalThis.fetch = createPreviewRegistryFetch({ + recordedSql, + tableColumnsByTable: { + devflare_preview_records: [ + 'id', + 'ver', + 'account_id', + 'worker_name', + 'version_id', + 'preview_url', + 'scope', + 'branch_name', + 'commit_sha', + 'source', + 'status', + 'created_by', + 'created_at', + 'updated_at', + 'deleted_at', + 'payload_json' + ], + devflare_preview_scope_records: [ + 'id', + 'ver', + 'account_id', + 'worker_name', + 'scope', + 'scope_url', + 'version_id', + 'branch_name', + 'commit_sha', + 'source', + 'status', + 'created_by', + 'created_at', + 'updated_at', + 'deleted_at', + 'payload_json' + ], + devflare_deployment_records: [ + 'id', + 'ver', + 'account_id', + 'worker_name', + 'deployment_id', + 'channel', + 'status', + 'version_id', + 'environment', + 'url', + 'commit_sha', + 'source', + 'created_by', + 'created_at', + 'updated_at', + 'deleted_at', + 'payload_json' + ] + }, + versionsItems: [ + { + id: defaultReconcileRequest.versionId, + number: 7, + metadata: { + author_id: 'user_123', + created_on: '2025-01-01T00:00:00.000Z', + modified_on: '2025-01-01T00:00:00.000Z', + hasPreview: true, + source: 'wrangler' + } + } + ], + deployments: [ + { + id: 'deployment_123', + created_on: '2025-01-02T00:00:00.000Z', + source: 'wrangler', + strategy: 'percentage', + versions: [ + { + percentage: 100, + version_id: defaultReconcileRequest.versionId + } + ], + annotations: { + 'workers/message': 'Deploy preview branch', + 'workers/triggered_by': 'upload' + }, + author_email: 'dev@example.com' + } + ] + }) + + const result = await reconcilePreviewRegistry(defaultReconcileRequest) + + expect(result.previews).toHaveLength(1) + expect(recordedSql).toContain( + 'ALTER TABLE "devflare_preview_records" ADD COLUMN scope_url TEXT' + ) + expect(recordedSql).toContain( + 'ALTER TABLE "devflare_preview_records" ADD COLUMN deployment_id TEXT' + ) + expect(recordedSql).toContain( + 'ALTER TABLE "devflare_preview_scope_records" ADD COLUMN preview_id TEXT' + ) + expect(recordedSql).toContain( + 'ALTER TABLE "devflare_deployment_records" ADD COLUMN preview_id TEXT' + ) + expect(recordedSql).toContain( + 'ALTER TABLE "devflare_deployment_records" ADD COLUMN message TEXT' + ) + }) + + test('retires a targeted preview, scope, and preview deployment without touching production records', async () => { + process.env.CLOUDFLARE_API_TOKEN = 'cf_test_token' + const recordedStatements: Array<{ sql: string; params: unknown[] }> = [] + globalThis.fetch = createPreviewRegistryFetch({ + recordedStatements, + previewRecords: [ + createPreviewRecordFixture({ + workerName: 'demo-worker', + versionId: defaultReconcileRequest.versionId, + previewUrl: defaultReconcileRequest.previewUrl, + scope: defaultReconcileRequest.previewScope, + scopeUrl: defaultReconcileRequest.previewScopeUrl, + branchName: defaultReconcileRequest.branchName + }) + ], + previewScopeRecords: [ + createPreviewScopeRecordFixture({ + workerName: 'demo-worker', + scope: defaultReconcileRequest.previewScope, + scopeUrl: defaultReconcileRequest.previewScopeUrl, + versionId: defaultReconcileRequest.versionId, + branchName: defaultReconcileRequest.branchName + }) + ], + deploymentRecords: [ + createDeploymentRecordFixture({ + id: 'deployment:demo-worker:preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + workerName: 'demo-worker', + deploymentId: 'preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + channel: 'preview', + versionId: defaultReconcileRequest.versionId, + previewId: 'preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569', + environment: 'preview', + url: defaultReconcileRequest.previewScopeUrl + }), + createDeploymentRecordFixture({ + id: 'deployment:demo-worker:deployment_123', + workerName: 'demo-worker', + deploymentId: 'deployment_123', + channel: 'production', + versionId: '7dba9570-33c4-4375-b784-e1b34ad01569', + environment: 'production', + url: 'https://demo-worker.example-subdomain.workers.dev', + createdAt: '2025-01-03T00:00:00.000Z', + updatedAt: '2025-01-03T00:00:00.000Z' + }) + ] + }) + + const result = await retirePreviewRegistry({ + accountId: 'acc_123', + workerName: 'demo-worker', + previewScope: 'feature-branch', + apply: true + }) + + expect(result.candidates.previews).toHaveLength(1) + expect(result.candidates.scopes).toHaveLength(1) + expect(result.candidates.deployments).toHaveLength(1) + expect(result.candidates.deployments[0].channel).toBe('preview') + expect( + recordedStatements.some((statement) => { + return ( + statement.sql.startsWith('INSERT INTO devflare_deployment_records') && + statement.params.includes('preview:demo-worker:5dba9570-33c4-4375-b784-e1b34ad01569') + ) + }) + ).toBe(true) + expect( + recordedStatements.some((statement) => { + return ( + statement.sql.startsWith('INSERT INTO devflare_deployment_records') && + statement.params.includes('deployment_123') + ) + }) + ).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/registry-schema.test.ts b/packages/devflare/tests/unit/cloudflare/registry-schema.test.ts new file mode 100644 index 0000000..4b03e62 --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/registry-schema.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' +import { + devflareAccountRecordSchema, + devflarePreviewRecordSchema, + devflarePreviewScopeRecordSchema, + devflareDeploymentRecordSchema, + devflareAccountLayerRecordSchema +} from '../../../src/cloudflare/registry-schema' + +const TEST_ACCOUNT_ID = 'test-account-id' + +describe('devflareAccountRecordSchema', () => { + test('coerces timestamps to Date and normalizes numeric creator ids', () => { + const record = devflareAccountRecordSchema.parse({ + id: 'preview:base', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + updatedAt: '2026-04-08T12:05:00.000Z', + createdBy: 42 + }) + + expect(record.createdAt).toBeInstanceOf(Date) + expect(record.updatedAt).toBeInstanceOf(Date) + expect(record.createdBy).toBe('42') + }) +}) + +describe('devflarePreviewRecordSchema', () => { + test('accepts preview records with scope metadata', () => { + const record = devflarePreviewRecordSchema.parse({ + id: 'preview:documentation:5dba9570', + kind: 'preview', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + createdBy: 'user-123', + accountId: TEST_ACCOUNT_ID, + workerName: 'documentation', + versionId: '5dba9570-33c4-4375-b784-e1b34ad01569', + previewUrl: 'https://5dba9570-documentation.refz.workers.dev/', + scope: 'acceptance-sweep', + scopeUrl: 'https://acceptance-sweep-documentation.refz.workers.dev/', + branchName: 'feature/preview-registry', + commitSha: 'abcdef1234567890', + source: 'cli' + }) + + expect(record.status).toBe('active') + expect(record.source).toBe('cli') + }) + + test('rejects scope URLs when no scope is present', () => { + expect(() => { + devflarePreviewRecordSchema.parse({ + id: 'preview:documentation:orphan', + kind: 'preview', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + createdBy: 'user-123', + accountId: TEST_ACCOUNT_ID, + workerName: 'documentation', + versionId: '5dba9570-33c4-4375-b784-e1b34ad01569', + previewUrl: 'https://5dba9570-documentation.refz.workers.dev/', + scopeUrl: 'https://acceptance-sweep-documentation.refz.workers.dev/' + }) + }).toThrow('scopeUrl requires scope to be set') + }) +}) + +describe('devflarePreviewScopeRecordSchema', () => { + test('enforces Cloudflare-safe preview names', () => { + expect(() => { + devflarePreviewScopeRecordSchema.parse({ + id: 'previewScope:documentation:Invalid Alias', + kind: 'previewScope', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + createdBy: 'user-123', + accountId: TEST_ACCOUNT_ID, + workerName: 'documentation', + scope: 'Invalid Alias', + scopeUrl: 'https://acceptance-sweep-documentation.refz.workers.dev/', + versionId: '5dba9570-33c4-4375-b784-e1b34ad01569' + }) + }).toThrow('Preview names must start with a lowercase letter') + }) +}) + +describe('devflareDeploymentRecordSchema', () => { + test('requires preview deployments to reference a preview record', () => { + expect(() => { + devflareDeploymentRecordSchema.parse({ + id: 'deployment:documentation:preview:1', + kind: 'deployment', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + createdBy: 'user-123', + accountId: TEST_ACCOUNT_ID, + workerName: 'documentation', + deploymentId: 'deployment-preview-1', + channel: 'preview', + versionId: '5dba9570-33c4-4375-b784-e1b34ad01569' + }) + }).toThrow('Preview deployments must reference the preview record they materialize') + }) +}) + +describe('devflareAccountLayerRecordSchema', () => { + test('parses discriminated account-layer records', () => { + const record = devflareAccountLayerRecordSchema.parse({ + id: 'deployment:documentation:production:1', + kind: 'deployment', + ver: 1, + createdAt: '2026-04-08T12:00:00.000Z', + createdBy: 'user-123', + accountId: TEST_ACCOUNT_ID, + workerName: 'documentation', + deploymentId: 'deployment-production-1', + channel: 'production', + versionId: '39f82f43-df67-4050-af54-6dcbca5585b5', + status: 'active', + source: 'github-action' + }) + + if (record.kind !== 'deployment') { + throw new Error(`Expected deployment record, received ${record.kind}`) + } + + expect(record.channel).toBe('production') + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/tokens.test.ts b/packages/devflare/tests/unit/cloudflare/tokens.test.ts new file mode 100644 index 0000000..1070fdb --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/tokens.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test } from 'bun:test' +import { + filterDevflareManagedTokens, + matchesKnownPermissionGroup, + normalizeDevflareTokenName, + selectAllReusablePermissionGroups, + selectDevflarePermissionGroups, + stripDevflareTokenNamePrefix +} from '../../../src/cloudflare/tokens' + +describe('selectDevflarePermissionGroups', () => { + test('keeps Devflare-relevant permission groups and excludes unrelated ones', () => { + const selected = selectDevflarePermissionGroups([ + { + id: 'account-api-tokens-write', + name: 'Account API Tokens Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'workers-scripts-write', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'd1-write', + name: 'D1 Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'browser-rendering-read', + name: 'Browser Rendering Read', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'account-waf-write', + name: 'Account WAF Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + + expect(selected.map((group) => group.id)).toEqual([ + 'workers-scripts-write', + 'd1-write', + 'browser-rendering-read' + ]) + }) + + test('keeps account and zone-scoped Devflare permission groups but excludes user-scoped matches', () => { + const selected = selectDevflarePermissionGroups([ + { + id: 'workers-routes-write-zone', + name: 'Workers Routes Write', + scopes: ['com.cloudflare.api.account.zone'] + }, + { + id: 'workers-scripts-write-account', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'workers-scripts-write-user', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.user'] + } + ]) + + expect(selected.map((group) => group.id)).toEqual([ + 'workers-routes-write-zone', + 'workers-scripts-write-account' + ]) + }) + + test('throws when nothing matches the Devflare permission set', () => { + expect(() => { + selectDevflarePermissionGroups([ + { + id: 'account-waf-write', + name: 'Account WAF Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + }).toThrow('Could not map the available Cloudflare permission groups') + }) + + test('keeps every Read/Write/Edit/Admin variant for each Devflare-managed product so deploy provisioning never fails on a missing variant', () => { + // The deploy pipeline lists, creates and updates resources across every + // product family below โ€” a missing variant on the resulting token + // surfaces as `ERROR Deployment failed: Could not list ...` + // during `bunx devflare deploy`, so this test guards that all common + // verbs survive selection per product. + const productVariantFixtures: ReadonlyArray<{ + productName: string + variants: ReadonlyArray + }> = [ + { productName: 'R2', variants: ['Read', 'Write', 'Edit', 'Admin'] }, + { productName: 'D1', variants: ['Read', 'Write', 'Edit', 'Admin', 'Metadata Read'] }, + { productName: 'Workers Scripts', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Workers Routes', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Workers KV Storage', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Workers R2 Storage', variants: ['Read', 'Write', 'Edit', 'Bucket Item Read', 'Bucket Item Write'] }, + { productName: 'Queues', variants: ['Read', 'Write', 'Edit', 'Admin'] }, + { productName: 'Hyperdrive', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Vectorize', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'AI', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Browser Rendering', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Pages', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Email Routing', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Images', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Stream', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Logs', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Logpush', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'DNS', variants: ['Read', 'Write', 'Edit'] }, + { productName: 'Cache Purge', variants: [''] } + ] + + const fixtures = productVariantFixtures.flatMap(({ productName, variants }) => { + return variants.map((variant) => { + const fullName = variant === '' ? productName : `${productName} ${variant}` + return { + id: fullName.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + name: fullName, + scopes: [ + ['Workers Routes', 'DNS', 'Cache Purge'].includes(productName) + ? 'com.cloudflare.api.account.zone' + : 'com.cloudflare.api.account' + ] + } + }) + }) + + const selectedNames = new Set( + selectDevflarePermissionGroups(fixtures).map((group) => group.name) + ) + + const missing: string[] = [] + for (const { productName, variants } of productVariantFixtures) { + for (const variant of variants) { + const fullName = variant === '' ? productName : `${productName} ${variant}` + if (!selectedNames.has(fullName)) { + missing.push(fullName) + } + } + } + + expect(missing).toEqual([]) + }) + + test('still excludes Account API Tokens permission groups even when other Account-prefixed groups are loosened', () => { + // `Account API Tokens Write/Read` lets a token rotate / delete other + // tokens โ€” that authority must never end up on a deploy token, even + // after loosening `Account Settings` / `Account Analytics` patterns. + const selected = selectDevflarePermissionGroups([ + { + id: 'account-api-tokens-write', + name: 'Account API Tokens Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'account-api-tokens-read', + name: 'Account API Tokens Read', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'account-settings-edit', + name: 'Account Settings Edit', + scopes: ['com.cloudflare.api.account'] + } + ]) + + expect(selected.map((group) => group.id)).toEqual(['account-settings-edit']) + }) + + test('keeps every reusable permission group for all-flags mode but still excludes token-management groups', () => { + const selected = selectAllReusablePermissionGroups([ + { + id: 'account-api-tokens-write', + name: 'Account API Tokens Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'workers-scripts-write', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'vectorize-write', + name: 'Vectorize Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + + expect(selected.map((group) => group.id)).toEqual([ + 'workers-scripts-write', + 'vectorize-write' + ]) + }) + + test('keeps account and zone-scoped reusable permission groups for all-flags mode', () => { + const selected = selectAllReusablePermissionGroups([ + { + id: 'workers-scripts-write-account', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account'] + }, + { + id: 'workers-scripts-write-zone', + name: 'Workers Scripts Write', + scopes: ['com.cloudflare.api.account.zone'] + }, + { + id: 'vectorize-write-user', + name: 'Vectorize Write', + scopes: ['com.cloudflare.api.user'] + }, + { + id: 'vectorize-write-account', + name: 'Vectorize Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + + expect(selected.map((group) => group.id)).toEqual([ + 'workers-scripts-write-account', + 'workers-scripts-write-zone', + 'vectorize-write-account' + ]) + }) + + test('filters large mixed-scope permission catalogs below Cloudflare\'s limit', () => { + const accountScopedGroups = Array.from({ length: 200 }, (_, index) => ({ + id: `account-${index + 1}`, + name: `Reusable Account Permission ${index + 1}`, + scopes: ['com.cloudflare.api.account'] + })) + const zoneScopedGroups = Array.from({ length: 99 }, (_, index) => ({ + id: `zone-${index + 1}`, + name: `Reusable Zone Permission ${index + 1}`, + scopes: ['com.cloudflare.api.account.zone'] + })) + + const selected = selectAllReusablePermissionGroups([ + ...accountScopedGroups, + ...zoneScopedGroups, + { + id: 'account-api-tokens-write', + name: 'Account API Tokens Write', + scopes: ['com.cloudflare.api.account'] + } + ]) + + expect(selected).toHaveLength(299) + expect(selected.filter((group) => group.id.startsWith('account-'))).toHaveLength(200) + expect(selected.filter((group) => group.id.startsWith('zone-'))).toHaveLength(99) + }) + + test('normalizes Devflare-managed token names to the devflare- prefix', () => { + expect(normalizeDevflareTokenName('preview')).toBe('devflare-preview') + expect(normalizeDevflareTokenName('devflare-preview')).toBe('devflare-preview') + }) + + test('strips the devflare- prefix for display without changing unprefixed names', () => { + expect(stripDevflareTokenNamePrefix('devflare-preview')).toBe('preview') + expect(stripDevflareTokenNamePrefix('preview')).toBe('preview') + }) + + test('filters account-owned tokens down to Devflare-managed names', () => { + const filtered = filterDevflareManagedTokens([ + { + id: 'token_1', + name: 'devflare-preview', + status: 'active' + }, + { + id: 'token_2', + name: 'manual-token', + status: 'active' + } + ]) + + expect(filtered.map((token) => token.id)).toEqual(['token_1']) + }) +}) + +describe('matchesKnownPermissionGroup', () => { + test('prefers id match over display name and falls back to exact display-name match with warning', () => { + const knownIds = { + WORKERS_SCRIPTS_WRITE: 'verified-workers-scripts-write-id', + WORKERS_SCRIPTS_READ: undefined, + ACCOUNT_SETTINGS_READ: undefined, + WORKERS_KV_STORAGE_WRITE: undefined, + WORKERS_KV_STORAGE_READ: undefined, + ACCOUNT_API_TOKENS_WRITE: undefined, + ACCOUNT_API_TOKENS_READ: undefined + } + const knownDisplayNames = { + WORKERS_SCRIPTS_WRITE: 'Workers Scripts Write', + WORKERS_SCRIPTS_READ: 'Workers Scripts Read', + ACCOUNT_SETTINGS_READ: 'Account Settings Read', + WORKERS_KV_STORAGE_WRITE: 'Workers KV Storage Write', + WORKERS_KV_STORAGE_READ: 'Workers KV Storage Read', + ACCOUNT_API_TOKENS_WRITE: 'Account API Tokens Write', + ACCOUNT_API_TOKENS_READ: 'Account API Tokens Read' + } + + const permissionsList = [ + // id-matched โ€” display name deliberately different / renamed + { + id: 'verified-workers-scripts-write-id', + name: 'Workers Scripts: Write (renamed by Cloudflare)', + scopes: ['com.cloudflare.api.account'] + }, + // display-name-matched โ€” id not in the known map + { + id: 'some-unrelated-id-for-read', + name: 'Workers Scripts Read', + scopes: ['com.cloudflare.api.account'] + }, + // should NOT match either โ€” substring-only name + { + id: 'unrelated', + name: 'Workers Scripts Write Delegated', + scopes: ['com.cloudflare.api.account'] + }, + // should NOT match โ€” wrong id and wrong name + { + id: 'other', + name: 'Some Other Group', + scopes: ['com.cloudflare.api.account'] + } + ] + + const warnCalls: string[] = [] + const originalWarn = console.warn + console.warn = (...args: unknown[]) => { + warnCalls.push(args.map(String).join(' ')) + } + + try { + const writeMatches = permissionsList.filter((group) => + matchesKnownPermissionGroup('WORKERS_SCRIPTS_WRITE', group, { + knownIds, + knownDisplayNames + }) + ) + const readMatches = permissionsList.filter((group) => + matchesKnownPermissionGroup('WORKERS_SCRIPTS_READ', group, { + knownIds, + knownDisplayNames + }) + ) + + // Id-matched: matches only the exact id, even though the display name drifted + expect(writeMatches.map((group) => group.id)).toEqual([ + 'verified-workers-scripts-write-id' + ]) + // Display-name fallback: matches ONLY the exact name, not substrings + expect(readMatches.map((group) => group.id)).toEqual([ + 'some-unrelated-id-for-read' + ]) + + // No warning for id-matched path + expect( + warnCalls.some((message) => message.includes('WORKERS_SCRIPTS_WRITE')) + ).toBe(false) + // Warning emitted for display-name fallback path + expect( + warnCalls.some((message) => message.includes('WORKERS_SCRIPTS_READ')) + ).toBe(true) + } finally { + console.warn = originalWarn + } + }) +}) diff --git a/packages/devflare/tests/unit/cloudflare/usage.test.ts b/packages/devflare/tests/unit/cloudflare/usage.test.ts new file mode 100644 index 0000000..1925c9f --- /dev/null +++ b/packages/devflare/tests/unit/cloudflare/usage.test.ts @@ -0,0 +1,101 @@ +// ============================================================================= +// usage.recordUsage() retry-path tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { recordUsage, type RecordUsageDeps } from '../../../src/cloudflare/usage' +import type { UsageRecord } from '../../../src/cloudflare/types' + +interface KvState { + value: string | null + // Simulated concurrent writers: each element is invoked once, per write, + // and may mutate `state.value` to model a clobbering concurrent update. + concurrentWrites: Array<(state: KvState) => void> + writes: string[] +} + +function createDeps(state: KvState, overrides: Partial = {}): RecordUsageDeps { + return { + getNamespaceId: async () => 'ns-1', + kvGet: async () => state.value, + kvPut: async (_accountId, _namespaceId, _key, value) => { + state.value = value + state.writes.push(value) + const next = state.concurrentWrites.shift() + if (next) next(state) + }, + sleep: async () => { }, + maxAttempts: 5, + ...overrides + } +} + +describe('recordUsage', () => { + test('retries when a concurrent writer clobbers the update and eventually succeeds', async () => { + const state: KvState = { + value: JSON.stringify({ + service: 'ai', + date: '2026-04-17', + count: 10, + updatedAt: '2026-04-17T00:00:00.000Z' + } satisfies UsageRecord), + writes: [], + concurrentWrites: [ + // After our first PUT, a concurrent writer clobbers the value + // with a different count + updatedAt so our verify fails. + (s) => { + s.value = JSON.stringify({ + service: 'ai', + date: '2026-04-17', + count: 999, + updatedAt: '1999-01-01T00:00:00.000Z' + } satisfies UsageRecord) + } + ] + } + + let nowCalls = 0 + const deps = createDeps(state, { + now: () => { + nowCalls++ + return new Date(`2026-04-17T00:00:00.${String(nowCalls).padStart(3, '0')}Z`) + } + }) + + const result = await recordUsage('account-1', 'ai', 1, deps) + + // First attempt reads count=10, writes 11, concurrent writer clobbers to 999. + // Retry reads count=999, writes 1000, verify succeeds. + expect(result.count).toBe(1000) + expect(state.writes.length).toBe(2) + }) + + test('warns and returns last-written record when retry budget is exhausted', async () => { + const state: KvState = { + value: null, + writes: [], + // Every write is immediately clobbered. + concurrentWrites: Array.from({ length: 10 }, () => (s: KvState) => { + s.value = JSON.stringify({ + service: 'vectorize', + date: '2026-04-17', + count: 42, + updatedAt: 'clobbered' + } satisfies UsageRecord) + }) + } + + const warnings: string[] = [] + const deps = createDeps(state, { + maxAttempts: 3, + warn: (message) => warnings.push(message) + }) + + const result = await recordUsage('account-1', 'vectorize', 5, deps) + + expect(state.writes.length).toBe(3) + expect(warnings.length).toBe(1) + expect(warnings[0]).toMatch(/best-effort/) + expect(result.service).toBe('vectorize') + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler.test.ts b/packages/devflare/tests/unit/config/compiler.test.ts new file mode 100644 index 0000000..9222965 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler.test.ts @@ -0,0 +1,16 @@ +import './compiler/01-basic-fields' +import './compiler/02-bindings' +import './compiler/03-triggers' +import './compiler/04-vars' +import './compiler/05-routes' +import './compiler/06-assets' +import './compiler/07-observability' +import './compiler/08-limits' +import './compiler/09-containers' +import './compiler/10-placement' +import './compiler/11-module-rules' +import './compiler/12-migrations' +import './compiler/13-passthrough' +import './compiler/14-environment-merging' +import './compiler/15-rebasewranglerconfigpaths' +import './compiler/compile-do-worker-config' diff --git a/packages/devflare/tests/unit/config/compiler/01-basic-fields.ts b/packages/devflare/tests/unit/config/compiler/01-basic-fields.ts new file mode 100644 index 0000000..565f73d --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/01-basic-fields.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('basic fields', () => { + test('compiles minimal config', () => { + const result = compileConfig(baseConfig) + + expect(result.name).toBe('my-worker') + expect(result.compatibility_date).toBe('2025-01-07') + expect(result.compatibility_flags).toEqual(['nodejs_compat', 'nodejs_als']) + }) + + test('defaults preview urls and workers.dev to enabled', () => { + const result = compileConfig(baseConfig) + + expect(result.preview_urls).toBe(true) + expect(result.workers_dev).toBe(true) + }) + + test('includes account_id when set', () => { + const result = compileConfig({ + ...baseConfig, + accountId: 'abc123def456' + }) + + expect(result.account_id).toBe('abc123def456') + }) + + test('includes main entry point from files.fetch', () => { + const result = compileConfig({ + ...baseConfig, + files: { + fetch: './src/index.ts' + } + }) + + expect(result.main).toBe('./src/index.ts') + }) + + test('includes compatibility flags', () => { + const result = compileConfig({ + ...baseConfig, + compatibilityFlags: ['nodejs_compat_v2', 'url_standard'] + }) + + expect(result.compatibility_flags).toEqual([ + 'nodejs_compat', + 'nodejs_als', + 'nodejs_compat_v2', + 'url_standard' + ]) + }) + + test('normalizes compatibility flags for already-resolved build configs', () => { + const result = compileBuildConfig( + { + ...baseConfig, + compatibilityFlags: ['url_standard'] + }, + undefined, + { alreadyResolved: true } + ) + + expect(result.compatibility_flags).toEqual(['nodejs_compat', 'nodejs_als', 'url_standard']) + }) + + test('compiles required secret declarations for Wrangler local/deploy validation', () => { + const result = compileConfig({ + ...baseConfig, + secrets: { + API_TOKEN: { required: true }, + OPTIONAL_TOKEN: { required: false } + } + }) + + expect(result.secrets).toEqual({ + required: ['API_TOKEN'] + }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/02-bindings.ts b/packages/devflare/tests/unit/config/compiler/02-bindings.ts new file mode 100644 index 0000000..b02f012 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/02-bindings.ts @@ -0,0 +1,809 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('bindings', () => { + test('materializes preview-scoped bindings before compilation', () => { + const pv = preview.scope() + const result = compileConfig({ + ...baseConfig, + bindings: { + r2: { BUCKET: pv('my-bucket') }, + queues: { + producers: { JOBS: pv('jobs-queue') }, + consumers: [{ queue: pv('jobs-queue') }] + }, + vectorize: { + SEARCH_INDEX: { indexName: pv('search-index') } + }, + browser: { BROWSER: pv('browser-renderer') }, + analyticsEngine: { + ANALYTICS: { dataset: pv('analytics-dataset') } + } + } + }) + + expect(result.r2_buckets).toEqual([{ binding: 'BUCKET', bucket_name: 'my-bucket' }]) + expect(result.queues?.producers).toEqual([{ binding: 'JOBS', queue: 'jobs-queue' }]) + expect(result.queues?.consumers).toEqual([{ queue: 'jobs-queue' }]) + expect(result.vectorize).toEqual([{ binding: 'SEARCH_INDEX', index_name: 'search-index' }]) + expect(result.browser).toEqual({ binding: 'BROWSER' }) + expect(result.analytics_engine_datasets).toEqual([ + { binding: 'ANALYTICS', dataset: 'analytics-dataset' } + ]) + }) + + test('compiles KV bindings configured with explicit id objects', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + kv: { CACHE: { id: 'kv-id-123' } } + } + }) + + expect(result.kv_namespaces).toEqual([{ binding: 'CACHE', id: 'kv-id-123' }]) + }) + + test('throws for unresolved KV name bindings configured with string shorthand', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + kv: { CACHE: 'cache-kv' } + } + }) + ).toThrow('configured by name (cache-kv)') + }) + + test('throws for unresolved KV name bindings configured with { name }', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + kv: { CACHE: { name: 'cache-kv' } } + } + }) + ).toThrow('loadResolvedConfig() or resolveConfigResources()') + }) + + test('preserves KV names in build artifacts', () => { + const result = compileBuildConfig({ + ...baseConfig, + bindings: { + kv: { CACHE: { name: 'cache-kv' } } + } + }) + + expect(result.kv_namespaces).toEqual([{ binding: 'CACHE', name: 'cache-kv' }]) + }) + + test('treats D1 string shorthand as an unresolved database name', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + d1: { DB: 'app-db' } + } + }) + ).toThrow('configured by name (app-db)') + }) + + test('compiles D1 bindings configured with explicit id objects', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + d1: { DB: { id: 'd1-id-789' } } + } + }) + + expect(result.d1_databases).toEqual([{ binding: 'DB', database_id: 'd1-id-789' }]) + }) + + test('throws for unresolved D1 name bindings', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + d1: { DB: { name: 'main-database' } } + } + }) + ).toThrow('loadResolvedConfig() or resolveConfigResources()') + }) + + test('preserves D1 names in build artifacts', () => { + const result = compileBuildConfig({ + ...baseConfig, + bindings: { + d1: { DB: { name: 'main-database' } } + } + }) + + expect(result.d1_databases).toEqual([{ binding: 'DB', database_name: 'main-database' }]) + }) + + test('compiles R2 bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + r2: { BUCKET: 'my-bucket' } + } + }) + + expect(result.r2_buckets).toEqual([{ binding: 'BUCKET', bucket_name: 'my-bucket' }]) + }) + + test('compiles Durable Object bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + durableObjects: { + COUNTER: { className: 'Counter' } + } + } + }) + + expect(result.durable_objects?.bindings).toEqual([{ name: 'COUNTER', class_name: 'Counter' }]) + }) + + test('compiles Durable Object bindings with script name', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + durableObjects: { + COUNTER: { className: 'Counter', scriptName: 'other-worker' } + } + } + }) + + expect(result.durable_objects?.bindings).toEqual([ + { name: 'COUNTER', class_name: 'Counter', script_name: 'other-worker' } + ]) + }) + + test('compiles Agents SDK Durable Object bindings and migrations', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + durableObjects: { + ChatAgent: { + className: 'ChatAgent' + } + } + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ChatAgent'] + } + ] + }) + + expect(result.durable_objects?.bindings).toEqual([ + { + name: 'ChatAgent', + class_name: 'ChatAgent' + } + ]) + expect(result.migrations).toEqual([ + { + tag: 'v1', + new_sqlite_classes: ['ChatAgent'] + } + ]) + }) + + test('compiles Queue producer bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + queues: { + producers: { QUEUE: 'my-queue' } + } + } + }) + + expect(result.queues?.producers).toEqual([{ binding: 'QUEUE', queue: 'my-queue' }]) + }) + + test('compiles Queue consumer bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + queues: { + consumers: [{ queue: 'my-queue', maxBatchSize: 10, maxRetries: 3 }] + } + } + }) + + expect(result.queues?.consumers).toEqual([ + { queue: 'my-queue', max_batch_size: 10, max_retries: 3 } + ]) + }) + + test('compiles Tail Consumers', () => { + const result = compileConfig({ + ...baseConfig, + tailConsumers: [ + 'observability-tail', + { + service: 'staging-observability-tail', + environment: 'staging' + } + ] + }) + + expect(result.tail_consumers).toEqual([ + { service: 'observability-tail' }, + { service: 'staging-observability-tail', environment: 'staging' } + ]) + }) + + test('compiles Rate Limiting bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { + limit: 100, + period: 60 + } + } + } + } + }) + + expect(result.ratelimits).toEqual([ + { + name: 'MY_RATE_LIMITER', + namespace_id: '1001', + simple: { + limit: 100, + period: 60 + } + } + ]) + }) + + test('compiles Version Metadata binding', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } + }) + + expect(result.version_metadata).toEqual({ + binding: 'CF_VERSION_METADATA' + }) + }) + + test('compiles Worker Loader bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + workerLoaders: { + LOADER: {} + } + } + }) + + expect(result.worker_loaders).toEqual([{ binding: 'LOADER' }]) + }) + + test('compiles mTLS Certificate bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123', + remote: true + } + } + } + }) + + expect(result.mtls_certificates).toEqual([ + { + binding: 'API_CERT', + certificate_id: 'cert-123', + remote: true + } + ]) + }) + + test('compiles Dispatch Namespace bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers', + outbound: { + service: 'outbound-worker', + environment: 'production', + parameters: ['ctx'] + }, + remote: true + } + } + } + }) + + expect(result.dispatch_namespaces).toEqual([ + { + binding: 'DISPATCHER', + namespace: 'customers', + outbound: { + service: 'outbound-worker', + environment: 'production', + parameters: ['ctx'] + }, + remote: true + } + ]) + }) + + test('compiles Workflow bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow', + scriptName: 'workflow-worker', + remote: true, + limits: { + steps: 42 + } + } + } + } + }) + + expect(result.workflows).toEqual([ + { + binding: 'ORDER_WORKFLOW', + name: 'orders', + class_name: 'OrderWorkflow', + script_name: 'workflow-worker', + remote: true, + limits: { + steps: 42 + } + } + ]) + }) + + test('compiles Pipeline bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + pipelines: { + EVENTS: 'events-stream', + AUDIT: { + pipeline: 'audit-stream', + remote: true + } + } + } + }) + + expect(result.pipelines).toEqual([ + { + binding: 'EVENTS', + pipeline: 'events-stream' + }, + { + binding: 'AUDIT', + pipeline: 'audit-stream', + remote: true + } + ]) + }) + + test('compiles Images binding', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + images: { + IMAGES: { + remote: true + } + } + } + }) + + expect(result.images).toEqual({ + binding: 'IMAGES', + remote: true + }) + }) + + test('compiles Media Transformations binding', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + media: { + MEDIA: { + remote: true + } + } + } + }) + + expect(result.media).toEqual({ + binding: 'MEDIA', + remote: true + }) + }) + + test('compiles Artifacts bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + artifacts: { + ARTIFACTS: 'default', + ARCHIVE: { + namespace: 'archive', + remote: true + } + } + } + }) + + expect(result.artifacts).toEqual([ + { + binding: 'ARTIFACTS', + namespace: 'default' + }, + { + binding: 'ARCHIVE', + namespace: 'archive', + remote: true + } + ]) + }) + + test('compiles Secrets Store bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + } + } + }) + + expect(result.secrets_store_secrets).toEqual([ + { + binding: 'API_TOKEN', + store_id: 'store-123', + secret_name: 'api-token' + } + ]) + }) + + test('compiles Secrets Store shorthand with the worker default store id', () => { + const result = compileConfig({ + ...baseConfig, + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + ADMIN_TOKEN: { + storeId: 'store-admin', + secretName: 'admin-token' + } + } + } + }) + + expect(result.secrets_store_secrets).toEqual([ + { + binding: 'API_TOKEN', + store_id: 'store-123', + secret_name: 'api-token' + }, + { + binding: 'ADMIN_TOKEN', + store_id: 'store-admin', + secret_name: 'admin-token' + } + ]) + }) + + test('compiles Service bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + services: { + AUTH: { service: 'auth-worker' } + } + } + }) + + expect(result.services).toEqual([{ binding: 'AUTH', service: 'auth-worker' }]) + }) + + test('compiles Service bindings with named entrypoints', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + services: { + AUTH: { + service: 'auth-worker', + entrypoint: 'AdminEntrypoint', + environment: 'staging' + } + } + } + }) + + expect(result.services).toEqual([ + { + binding: 'AUTH', + service: 'auth-worker', + entrypoint: 'AdminEntrypoint', + environment: 'staging' + } + ]) + }) + + test('compiles AI binding with local-development flags', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + ai: { + binding: 'AI', + remote: true, + staging: true + } + } + }) + + expect(result.ai).toEqual({ + binding: 'AI', + remote: true, + staging: true + }) + }) + + test('compiles AI Search namespace and instance bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + aiSearchNamespaces: { + AI_SEARCH: { + namespace: 'default', + remote: true + } + }, + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs', + remote: true + } + } + } + }) + + expect(result.ai_search_namespaces).toEqual([ + { + binding: 'AI_SEARCH', + namespace: 'default', + remote: true + } + ]) + expect(result.ai_search).toEqual([ + { + binding: 'DOCS_SEARCH', + instance_name: 'docs', + remote: true + } + ]) + }) + + test('compiles Vectorize bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + vectorize: { + VECTOR_INDEX: { indexName: 'my-index', remote: true } + } + } + }) + + expect(result.vectorize).toEqual([ + { binding: 'VECTOR_INDEX', index_name: 'my-index', remote: true } + ]) + }) + + test('throws for unresolved Hyperdrive name bindings configured with string shorthand', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + hyperdrive: { + POSTGRES: 'devflare-testing' + } + } + }) + ).toThrow('configured by name (devflare-testing)') + }) + + test('compiles Hyperdrive bindings configured with explicit id objects', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + hyperdrive: { + POSTGRES: { + id: 'hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + }) + + expect(result.hyperdrive).toEqual([ + { + binding: 'POSTGRES', + id: 'hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + ]) + }) + + test('throws for unresolved Hyperdrive name bindings configured with { name }', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + hyperdrive: { + POSTGRES: { name: 'devflare-testing' } + } + } + }) + ).toThrow('loadResolvedConfig() or resolveConfigResources()') + }) + + test('preserves Hyperdrive names in build artifacts', () => { + const result = compileBuildConfig({ + ...baseConfig, + bindings: { + hyperdrive: { + POSTGRES: { + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + }) + + expect(result.hyperdrive).toEqual([ + { + binding: 'POSTGRES', + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + ]) + }) + + test('compiles Browser binding map syntax', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + browser: { BROWSER: 'browser-resource' } + } + }) + + expect(result.browser).toEqual({ binding: 'BROWSER' }) + }) + + test('compiles Browser binding object form with remote option', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + browser: { + BROWSER: { + remote: true + } + } + } + }) + + expect(result.browser).toEqual({ + binding: 'BROWSER', + remote: true + }) + }) + + test('throws when multiple Browser bindings are compiled', () => { + expect(() => + compileConfig({ + ...baseConfig, + bindings: { + browser: { + BROWSER_ONE: 'browser-one', + BROWSER_TWO: 'browser-two' + } + } + }) + ).toThrow('exactly one browser binding') + }) + + test('compiles Analytics Engine bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + analyticsEngine: { + ANALYTICS: { dataset: 'my-dataset' } + } + } + }) + + expect(result.analytics_engine_datasets).toEqual([ + { binding: 'ANALYTICS', dataset: 'my-dataset' } + ]) + }) + + test('compiles sendEmail bindings', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'admin@example.com', + allowedSenderAddresses: ['sender@example.com'] + }, + BULK_EMAIL: { + allowedDestinationAddresses: ['ops@example.com', 'team@example.com'] + } + } + } + }) + + expect(result.send_email).toEqual([ + { + name: 'EMAIL', + destination_address: 'admin@example.com', + allowed_sender_addresses: ['sender@example.com'] + }, + { + name: 'BULK_EMAIL', + allowed_destination_addresses: ['ops@example.com', 'team@example.com'] + } + ]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/03-triggers.ts b/packages/devflare/tests/unit/config/compiler/03-triggers.ts new file mode 100644 index 0000000..eb5d030 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/03-triggers.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('triggers', () => { + test('compiles cron triggers', () => { + const result = compileConfig({ + ...baseConfig, + triggers: { + crons: ['0 * * * *', '0 0 * * *'] + } + }) + + expect(result.triggers?.crons).toEqual(['0 * * * *', '0 0 * * *']) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/04-vars.ts b/packages/devflare/tests/unit/config/compiler/04-vars.ts new file mode 100644 index 0000000..43af387 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/04-vars.ts @@ -0,0 +1,43 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('vars', () => { + test('compiles environment variables', () => { + const result = compileConfig({ + ...baseConfig, + vars: { + API_URL: 'https://api.example.com', + DEBUG: 'true' + } + }) + + expect(result.vars).toEqual({ + API_URL: 'https://api.example.com', + DEBUG: 'true' + }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/05-routes.ts b/packages/devflare/tests/unit/config/compiler/05-routes.ts new file mode 100644 index 0000000..a78c2fa --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/05-routes.ts @@ -0,0 +1,37 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('routes', () => { + test('compiles routes array', () => { + const result = compileConfig({ + ...baseConfig, + routes: [{ pattern: 'example.com/*', zone_name: 'example.com' }] + }) + + expect(result.routes).toEqual([{ pattern: 'example.com/*', zone_name: 'example.com' }]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/06-assets.ts b/packages/devflare/tests/unit/config/compiler/06-assets.ts new file mode 100644 index 0000000..98e354e --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/06-assets.ts @@ -0,0 +1,49 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('assets', () => { + test('compiles assets config', () => { + const result = compileConfig({ + ...baseConfig, + assets: { + directory: './public', + binding: 'ASSETS', + html_handling: 'force-trailing-slash', + not_found_handling: 'single-page-application', + run_worker_first: ['/api/*', '!/api/docs/*'] + } + }) + + expect(result.assets).toEqual({ + directory: './public', + binding: 'ASSETS', + html_handling: 'force-trailing-slash', + not_found_handling: 'single-page-application', + run_worker_first: ['/api/*', '!/api/docs/*'] + }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/07-observability.ts b/packages/devflare/tests/unit/config/compiler/07-observability.ts new file mode 100644 index 0000000..d148c09 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/07-observability.ts @@ -0,0 +1,82 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('observability', () => { + test('compiles observability config', () => { + const result = compileConfig({ + ...baseConfig, + observability: { + enabled: true, + head_sampling_rate: 0.1 + } + }) + + expect(result.observability).toEqual({ + enabled: true, + head_sampling_rate: 0.1 + }) + }) + + test('compiles nested logs and traces observability config', () => { + const result = compileConfig({ + ...baseConfig, + observability: { + enabled: true, + logs: { + enabled: true, + head_sampling_rate: 0.25, + invocation_logs: false, + persist: false, + destinations: ['workers_logs'] + }, + traces: { + enabled: true, + head_sampling_rate: 0.1, + persist: true, + destinations: ['cloudflare'] + } + } + }) + + expect(result.observability).toEqual({ + enabled: true, + logs: { + enabled: true, + head_sampling_rate: 0.25, + invocation_logs: false, + persist: false, + destinations: ['workers_logs'] + }, + traces: { + enabled: true, + head_sampling_rate: 0.1, + persist: true, + destinations: ['cloudflare'] + } + }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/08-limits.ts b/packages/devflare/tests/unit/config/compiler/08-limits.ts new file mode 100644 index 0000000..0cf6f43 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/08-limits.ts @@ -0,0 +1,37 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('limits', () => { + test('compiles limits config', () => { + const result = compileConfig({ + ...baseConfig, + limits: { cpu_ms: 50, subrequests: 150 } + }) + + expect(result.limits).toEqual({ cpu_ms: 50, subrequests: 150 }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/09-containers.ts b/packages/devflare/tests/unit/config/compiler/09-containers.ts new file mode 100644 index 0000000..f4e7d03 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/09-containers.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('containers', () => { + test('compiles native Containers config to Wrangler containers', () => { + const result = compileConfig({ + ...baseConfig, + containers: [ + { + className: 'MyContainer', + image: './Dockerfile', + maxInstances: 2, + instanceType: 'basic', + name: 'api-container', + imageBuildContext: './container', + imageVars: { + NODE_VERSION: '22' + }, + rolloutActiveGracePeriod: 30, + rolloutStepPercentage: [10, 50, 100] + } + ] + }) + + expect(result.containers).toEqual([ + { + class_name: 'MyContainer', + image: './Dockerfile', + max_instances: 2, + instance_type: 'basic', + name: 'api-container', + image_build_context: './container', + image_vars: { + NODE_VERSION: '22' + }, + rollout_active_grace_period: 30, + rollout_step_percentage: [10, 50, 100] + } + ]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/10-placement.ts b/packages/devflare/tests/unit/config/compiler/10-placement.ts new file mode 100644 index 0000000..d0923d5 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/10-placement.ts @@ -0,0 +1,46 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('placement', () => { + test('compiles Smart Placement config', () => { + const result = compileConfig({ + ...baseConfig, + placement: { mode: 'smart' } + }) + + expect(result.placement).toEqual({ mode: 'smart' }) + }) + + test('compiles explicit Placement Hints config', () => { + const result = compileConfig({ + ...baseConfig, + placement: { region: 'aws:us-east-1' } + }) + + expect(result.placement).toEqual({ region: 'aws:us-east-1' }) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/11-module-rules.ts b/packages/devflare/tests/unit/config/compiler/11-module-rules.ts new file mode 100644 index 0000000..92ee632 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/11-module-rules.ts @@ -0,0 +1,51 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('module rules', () => { + test('compiles non-JavaScript module rules and additional module options', () => { + const result = compileConfig({ + ...baseConfig, + rules: [ + { type: 'Text', globs: ['**/*.txt'], fallthrough: true }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] } + ], + findAdditionalModules: true, + baseDir: './src', + preserveFileNames: true + }) + + expect(result.rules).toEqual([ + { type: 'Text', globs: ['**/*.txt'], fallthrough: true }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] } + ]) + expect(result.find_additional_modules).toBe(true) + expect(result.base_dir).toBe('./src') + expect(result.preserve_file_names).toBe(true) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/12-migrations.ts b/packages/devflare/tests/unit/config/compiler/12-migrations.ts new file mode 100644 index 0000000..7275390 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/12-migrations.ts @@ -0,0 +1,59 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('migrations', () => { + test('compiles migrations array', () => { + const result = compileConfig({ + ...baseConfig, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['Counter'] + }, + { + tag: 'v2', + new_classes: ['LegacyCounter'], + renamed_classes: [{ from: 'Counter', to: 'CounterV2' }], + deleted_classes: ['OldCounter'] + } + ] + }) + + expect(result.migrations).toEqual([ + { + tag: 'v1', + new_sqlite_classes: ['Counter'] + }, + { + tag: 'v2', + new_classes: ['LegacyCounter'], + renamed_classes: [{ from: 'Counter', to: 'CounterV2' }], + deleted_classes: ['OldCounter'] + } + ]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/13-passthrough.ts b/packages/devflare/tests/unit/config/compiler/13-passthrough.ts new file mode 100644 index 0000000..edbaa0e --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/13-passthrough.ts @@ -0,0 +1,135 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('passthrough', () => { + test('merges passthrough config at top level', () => { + const result = compileConfig({ + ...baseConfig, + wrangler: { + passthrough: { + unsafe: { + bindings: [{ name: 'BETA', type: 'custom' }] + }, + custom_field: 'value' + } + } + }) + + expect(result.unsafe).toEqual({ + bindings: [{ name: 'BETA', type: 'custom' }] + }) + expect(result.custom_field).toBe('value') + }) + + test('passes Containers config through for Wrangler-managed container deployments', () => { + const result = compileConfig({ + ...baseConfig, + wrangler: { + passthrough: { + containers: [ + { + class_name: 'MyContainer', + image: './Dockerfile', + max_instances: 5 + } + ] + } + } + }) + + expect(result.containers).toEqual([ + { + class_name: 'MyContainer', + image: './Dockerfile', + max_instances: 5 + } + ]) + }) + + test('passes Sandbox SDK container config through with the matching Durable Object binding', () => { + const result = compileConfig({ + ...baseConfig, + bindings: { + durableObjects: { + Sandbox: { + className: 'Sandbox' + } + } + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['Sandbox'] + } + ], + wrangler: { + passthrough: { + containers: [ + { + class_name: 'Sandbox', + image: './Dockerfile' + } + ] + } + } + }) + + expect(result.containers).toEqual([ + { + class_name: 'Sandbox', + image: './Dockerfile' + } + ]) + expect(result.durable_objects?.bindings).toEqual([ + { + name: 'Sandbox', + class_name: 'Sandbox' + } + ]) + expect(result.migrations).toEqual([ + { + tag: 'v1', + new_sqlite_classes: ['Sandbox'] + } + ]) + }) + + test('allows disabling preview urls and workers.dev via passthrough overrides', () => { + const result = compileConfig({ + ...baseConfig, + wrangler: { + passthrough: { + preview_urls: false, + workers_dev: false + } + } + }) + + expect(result.preview_urls).toBe(false) + expect(result.workers_dev).toBe(false) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/14-environment-merging.ts b/packages/devflare/tests/unit/config/compiler/14-environment-merging.ts new file mode 100644 index 0000000..abcb0a9 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/14-environment-merging.ts @@ -0,0 +1,93 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('environment merging', () => { + test('compiles with environment-specific overrides', () => { + const result = compileConfig( + { + ...baseConfig, + vars: { DEBUG: 'false' }, + env: { + staging: { + vars: { DEBUG: 'true' } + } + } + }, + 'staging' + ) + + expect(result.vars).toEqual({ DEBUG: 'true' }) + }) + + test('deep merges bindings for environment', () => { + const result = compileConfig( + { + ...baseConfig, + bindings: { + kv: { CACHE: { id: 'prod-kv-id' } } + }, + env: { + dev: { + bindings: { + kv: { CACHE: { id: 'dev-kv-id' } } + } + } + } + }, + 'dev' + ) + + expect(result.kv_namespaces).toEqual([{ binding: 'CACHE', id: 'dev-kv-id' }]) + }) + + test('replaces array fields for environment overrides instead of concatenating them', () => { + const result = compileConfig( + { + ...baseConfig, + routes: [{ pattern: 'root.example/*', zone_name: 'example.com' }], + triggers: { + crons: ['0 * * * *'] + }, + migrations: [{ tag: 'v1', new_classes: ['RootCounter'] }], + env: { + preview: { + routes: [{ pattern: 'preview.example/*', zone_name: 'example.com' }], + triggers: { + crons: ['0 0 * * *'] + }, + migrations: [{ tag: 'v2', new_classes: ['PreviewCounter'] }] + } + } + }, + 'preview' + ) + + expect(result.routes).toEqual([{ pattern: 'preview.example/*', zone_name: 'example.com' }]) + expect(result.triggers?.crons).toEqual(['0 0 * * *']) + expect(result.migrations).toEqual([{ tag: 'v2', new_classes: ['PreviewCounter'] }]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/15-rebasewranglerconfigpaths.ts b/packages/devflare/tests/unit/config/compiler/15-rebasewranglerconfigpaths.ts new file mode 100644 index 0000000..05d2443 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/15-rebasewranglerconfigpaths.ts @@ -0,0 +1,92 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +const baseConfig = brandAsLocalConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] +} satisfies DevflareConfig) + +describe('compileConfig', () => { + describe('rebaseWranglerConfigPaths', () => { + test('rebases main and assets.directory relative to the generated config directory', () => { + const rebased = rebaseWranglerConfigPaths('/project', '/project/.devflare/build', { + name: 'my-worker', + compatibility_date: '2025-01-07', + main: '.svelte-kit/cloudflare/_worker.js', + assets: { + directory: '.svelte-kit/cloudflare', + binding: 'ASSETS' + } + }) + + expect(rebased.main).toBe('../../.svelte-kit/cloudflare/_worker.js') + expect(rebased.assets).toEqual({ + directory: '../../.svelte-kit/cloudflare', + binding: 'ASSETS' + }) + }) + + test('preserves unrelated Wrangler fields while rebasing path fields', () => { + const rebased = rebaseWranglerConfigPaths('/project', '/project/.devflare', { + name: 'my-worker', + compatibility_date: '2025-01-07', + workers_dev: true, + assets: { + directory: 'public' + } + }) + + expect(rebased.workers_dev).toBe(true) + expect(rebased.assets).toEqual({ + directory: '../public' + }) + }) + + test('rebases local Container image paths and build contexts', () => { + const rebased = rebaseWranglerConfigPaths('/project', '/project/.devflare', { + name: 'my-worker', + compatibility_date: '2025-01-07', + containers: [ + { + class_name: 'MyContainer', + image: './Dockerfile', + image_build_context: './container' + }, + { + class_name: 'RegistryContainer', + image: 'ghcr.io/acme/app:local' + } + ] + }) + + expect(rebased.containers).toEqual([ + { + class_name: 'MyContainer', + image: '../Dockerfile', + image_build_context: '../container' + }, + { + class_name: 'RegistryContainer', + image: 'ghcr.io/acme/app:local' + } + ]) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/compiler/compile-do-worker-config.ts b/packages/devflare/tests/unit/config/compiler/compile-do-worker-config.ts new file mode 100644 index 0000000..ac0f353 --- /dev/null +++ b/packages/devflare/tests/unit/config/compiler/compile-do-worker-config.ts @@ -0,0 +1,99 @@ +// ============================================================================= +// Config Compiler Tests โ€” Transforms DevflareConfig to wrangler.jsonc +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { preview } from '../../../../src/config' + +import { + compileBuildConfig, + compileConfig, + compileDOWorkerConfig, + rebaseWranglerConfigPaths +} from '../../../../src/config/compiler' + +import { brandAsLocalConfig } from '../../../../src/config/resolve-phased' + +import type { DevflareConfig } from '../../../../src/config/schema' + +describe('compileDOWorkerConfig', () => { + const baseConfig: DevflareConfig = { + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] + } + + test('returns an empty array when no Durable Objects are configured', () => { + const results = compileDOWorkerConfig(baseConfig, 'src/workers/do.ts') + expect(results).toEqual([]) + }) + + test('produces one compiled-worker entry per DO class, named from the class', () => { + const results = compileDOWorkerConfig( + { + ...baseConfig, + bindings: { + durableObjects: { + COUNTER: { className: 'CounterObject' }, + CHAT: { className: 'ChatRoom' } + } + } + }, + 'src/workers/do.ts' + ) + + expect(results).toHaveLength(2) + + const counterWorker = results.find((r) => r.name === 'my-worker-counter-object') + const chatWorker = results.find((r) => r.name === 'my-worker-chat-room') + + expect(counterWorker).toBeDefined() + expect(chatWorker).toBeDefined() + + expect(counterWorker?.durable_objects).toEqual({ + bindings: [{ name: 'COUNTER', class_name: 'CounterObject' }] + }) + expect(chatWorker?.durable_objects).toEqual({ + bindings: [{ name: 'CHAT', class_name: 'ChatRoom' }] + }) + }) + + test('respects an explicit scriptName when provided', () => { + const results = compileDOWorkerConfig( + { + ...baseConfig, + bindings: { + durableObjects: { + COUNTER: { className: 'CounterObject', scriptName: 'custom-do-worker' } + } + } + }, + 'src/workers/do.ts' + ) + + expect(results).toHaveLength(1) + expect(results[0]?.name).toBe('custom-do-worker') + }) + + test('groups multiple bindings that share a class into a single worker', () => { + const results = compileDOWorkerConfig( + { + ...baseConfig, + bindings: { + durableObjects: { + PRIMARY: { className: 'CounterObject' }, + SECONDARY: { className: 'CounterObject' } + } + } + }, + 'src/workers/do.ts' + ) + + expect(results).toHaveLength(1) + expect(results[0]?.durable_objects?.bindings).toEqual([ + { name: 'PRIMARY', class_name: 'CounterObject' }, + { name: 'SECONDARY', class_name: 'CounterObject' } + ]) + }) +}) diff --git a/packages/devflare/tests/unit/config/define.test.ts b/packages/devflare/tests/unit/config/define.test.ts new file mode 100644 index 0000000..697049f --- /dev/null +++ b/packages/devflare/tests/unit/config/define.test.ts @@ -0,0 +1,51 @@ +// ============================================================================= +// defineConfig Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { defineConfig } from '../../../src/config/define' + +describe('defineConfig', () => { + test('returns config object unchanged', () => { + const config = defineConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07' + }) + + expect(config.name).toBe('my-worker') + expect(config.compatibilityDate).toBe('2025-01-07') + }) + + test('provides type safety for config', () => { + // This test verifies TypeScript compilation + const config = defineConfig({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + kv: { CACHE: 'cache-kv' }, + d1: { DB: 'primary-db' } + } + }) + + expect(config.bindings?.kv?.CACHE).toBe('cache-kv') + }) + + test('accepts function returning config', () => { + const config = defineConfig(() => ({ + name: 'dynamic-worker', + compatibilityDate: '2025-01-07' + })) + + expect(config.name).toBe('dynamic-worker') + }) + + test('accepts async function returning config', async () => { + const configFn = defineConfig(async () => ({ + name: 'async-worker', + compatibilityDate: '2025-01-07' + })) + + const config = await configFn + expect(config.name).toBe('async-worker') + }) +}) diff --git a/packages/devflare/tests/unit/config/env-vars.test.ts b/packages/devflare/tests/unit/config/env-vars.test.ts new file mode 100644 index 0000000..4301b92 --- /dev/null +++ b/packages/devflare/tests/unit/config/env-vars.test.ts @@ -0,0 +1,187 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { defineConfig, env } from '../../../src/config' +import { + EnvVarResolutionError, + loadDevflareDotenv, + resolveConfigEnvVars +} from '../../../src/config/env-vars' + +const tempDirs: string[] = [] + +function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-env-vars-')) + tempDirs.push(dir) + return dir +} + +function writeProjectFile(dir: string, name: string, contents: string): void { + writeFileSync(join(dir, name), contents) +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('loadDevflareDotenv', () => { + test('loads parent .env files first and lets closer files override them', async () => { + const root = makeTempProject() + const app = join(root, 'apps', 'site') + mkdirSync(app, { recursive: true }) + writeProjectFile(root, '.env', [ + 'SHARED=from-root', + 'ROOT_ONLY=yes', + 'RAW_VALUE=abc$not_expanded#not_comment' + ].join('\n')) + writeProjectFile(app, '.env', [ + 'SHARED=from-app', + 'APP_ONLY=yes' + ].join('\n')) + + const loaded = await loadDevflareDotenv(app) + + expect(loaded.values).toMatchObject({ + SHARED: 'from-app', + ROOT_ONLY: 'yes', + RAW_VALUE: 'abc$not_expanded#not_comment', + APP_ONLY: 'yes' + }) + }) + + test('loads .env.dev before .env so .env has final precedence', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.env.dev', [ + 'SHARED=from-dev', + 'DEV_ONLY=yes' + ].join('\n')) + writeProjectFile(cwd, '.env', [ + 'SHARED=from-env', + 'ENV_ONLY=yes' + ].join('\n')) + + const loaded = await loadDevflareDotenv(cwd) + + expect(loaded.values).toMatchObject({ + SHARED: 'from-env', + DEV_ONLY: 'yes', + ENV_ONLY: 'yes' + }) + }) +}) + +describe('resolveConfigEnvVars', () => { + test('resolves nested env descriptors, parsers, defaults, dev defaults, and optional vars', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.env', [ + 'SECRET=from-env', + 'MONGOURI=mongodb://localhost:27017', + 'MONGODATABASE=voices', + 'NUMBER=42.5' + ].join('\n')) + + const config = defineConfig({ + name: 'env-worker', + compatibilityDate: '2026-05-01', + vars: { + secret: env.SECRET, + mongo: { + uri: env.MONGOURI, + database: env.MONGODATABASE + }, + isNumber: env.NUMBER.parse(parseFloat), + optionalValue: env.NOT_PRESENT.optional(), + withDefault: env.DEFAULTED.default('fallback'), + devOnly: env.DEV_ONLY.dev(123) + } + }) + + const resolved = await resolveConfigEnvVars(config, { + cwd, + configPath: join(cwd, 'devflare.config.ts'), + mode: 'dev' + }) + + expect(resolved.vars).toEqual({ + secret: 'from-env', + mongo: { + uri: 'mongodb://localhost:27017', + database: 'voices' + }, + isNumber: 42.5, + withDefault: 'fallback', + devOnly: 123 + }) + }) + + test('throws a nested missing-variable report in build mode', async () => { + const cwd = makeTempProject() + const config = defineConfig({ + name: 'missing-env-worker', + compatibilityDate: '2026-05-01', + vars: { + secret: env.SECRET, + mongo: { + uri: env.MONGOURI + } + } + }) + + await expect(resolveConfigEnvVars(config, { + cwd, + configPath: join(cwd, 'devflare.config.ts'), + mode: 'build' + })).rejects.toThrow(EnvVarResolutionError) + + try { + await resolveConfigEnvVars(config, { + cwd, + configPath: join(cwd, 'devflare.config.ts'), + mode: 'build' + }) + } catch (error) { + expect(error).toBeInstanceOf(EnvVarResolutionError) + expect((error as Error).message).toContain('These environment variables are missing:') + expect((error as Error).message).toContain('secret: SECRET') + expect((error as Error).message).toContain('mongo:') + expect((error as Error).message).toContain('uri: MONGOURI') + } + }) + + test('lets process.env override dotenv values', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.env', 'DEVFLARE_TEST_PROCESS_OVERRIDE=from-file\n') + const previous = process.env.DEVFLARE_TEST_PROCESS_OVERRIDE + process.env.DEVFLARE_TEST_PROCESS_OVERRIDE = 'from-process' + + try { + const config = defineConfig({ + name: 'process-env-worker', + compatibilityDate: '2026-05-01', + vars: { + value: env.DEVFLARE_TEST_PROCESS_OVERRIDE + } + }) + + const resolved = await resolveConfigEnvVars(config, { + cwd, + configPath: join(cwd, 'devflare.config.ts'), + mode: 'build' + }) + + expect(resolved.vars).toEqual({ value: 'from-process' }) + } finally { + if (previous === undefined) { + delete process.env.DEVFLARE_TEST_PROCESS_OVERRIDE + } else { + process.env.DEVFLARE_TEST_PROCESS_OVERRIDE = previous + } + } + }) +}) diff --git a/packages/devflare/tests/unit/config/loader.test.ts b/packages/devflare/tests/unit/config/loader.test.ts new file mode 100644 index 0000000..bea09fe --- /dev/null +++ b/packages/devflare/tests/unit/config/loader.test.ts @@ -0,0 +1,285 @@ +// ============================================================================= +// Config Loader Tests โ€” Load devflare.config.ts via c12 +// ============================================================================= + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { join, relative } from 'pathe' +import { loadConfig, resolveConfigPath } from '../../../src/config/loader' +import { mkdir, rm, writeFile } from 'node:fs/promises' + +const TEST_DIR = join(import.meta.dirname, '../.fixtures/config-loader') +const WORKSPACE_ENV_KEYS = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'] as const + +describe('loadConfig', () => { + let originalWorkspaceEnv: Record = {} + + beforeEach(async () => { + originalWorkspaceEnv = Object.fromEntries( + WORKSPACE_ENV_KEYS.map((key) => [key, process.env[key]]) + ) as Record + + await mkdir(TEST_DIR, { recursive: true }) + }) + + afterEach(async () => { + for (const key of WORKSPACE_ENV_KEYS) { + const originalValue = originalWorkspaceEnv[key] + if (originalValue === undefined) { + delete process.env[key] + continue + } + + process.env[key] = originalValue + } + + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('loads config from devflare.config.ts', async () => { + const configPath = join(TEST_DIR, 'devflare.config.ts') + // Use direct export, not defineConfig since we're testing the loader + await writeFile(configPath, ` + export default { + name: 'test-worker', + compatibilityDate: '2025-01-07' + } + `) + + const config = await loadConfig({ cwd: TEST_DIR }) + + expect(config.name).toBe('test-worker') + }) + + test.skip('loads config from custom path', async () => { + // TODO: c12 has issues with custom config file names in test env + // This works correctly in real usage + const configPath = join(TEST_DIR, 'custom.config.ts') + // Use named export that c12 recognizes + await writeFile(configPath, ` +const config = { + name: 'custom-worker', + compatibilityDate: '2025-01-07' +} +export default config + `.trim()) + + const config = await loadConfig({ + cwd: TEST_DIR, + configFile: 'custom.config' // c12 adds extension automatically + }) + + expect(config.name).toBe('custom-worker') + }) + + test('throws when config file not found', async () => { + await expect(loadConfig({ cwd: TEST_DIR })).rejects.toThrow() + }) + + test('validates loaded config', async () => { + // The loader should validate configs. Since c12/jiti has caching + // issues in test environments, we test schema validation directly. + // The schema test suite covers the full validation behavior. + const configPath = join(TEST_DIR, 'devflare.config.ts') + await writeFile(configPath, ` + export default { + name: 'test-worker', + compatibilityDate: '2025-01-07' + } + `) + + // Valid config should load successfully + const config = await loadConfig({ cwd: TEST_DIR }) + expect(config.name).toBe('test-worker') + // Should have forced flags even though none were specified + expect(config.compatibilityFlags).toContain('nodejs_compat') + }) + + test('accepts relative cwd paths by normalizing them before loading config', async () => { + const projectDir = join(TEST_DIR, 'relative-cwd') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'relative-worker', + compatibilityDate: '2025-01-07' + } + `) + + const config = await loadConfig({ + cwd: relative(process.cwd(), projectDir) + }) + + expect(config.name).toBe('relative-worker') + }) + + test('infers SvelteKit Cloudflare worker and asset outputs when they are omitted', async () => { + const projectDir = join(TEST_DIR, 'sveltekit-inferred') + await mkdir(projectDir, { recursive: true }) + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'docs-app', + devDependencies: { + '@sveltejs/adapter-cloudflare': '^7.2.8', + '@sveltejs/kit': '^2.0.0' + } + }, null, 2)) + await writeFile(join(projectDir, 'svelte.config.js'), ` + import adapter from '@sveltejs/adapter-cloudflare' + + export default { + kit: { + adapter: adapter() + } + } + `) + await writeFile(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'docs-worker', + compatibilityDate: '2025-01-07' + } + `) + + const config = await loadConfig({ cwd: projectDir }) + + expect(config.files?.fetch).toBe('.adapter-cloudflare/_worker.js') + expect(config.assets).toEqual({ + binding: 'ASSETS', + directory: '.adapter-cloudflare' + }) + }) + + test('keeps explicit worker and asset settings over inferred framework defaults', async () => { + const projectDir = join(TEST_DIR, 'sveltekit-explicit') + await mkdir(projectDir, { recursive: true }) + + await writeFile(join(projectDir, 'package.json'), JSON.stringify({ + name: 'docs-app', + devDependencies: { + '@sveltejs/adapter-cloudflare': '^7.2.8', + '@sveltejs/kit': '^2.0.0' + } + }, null, 2)) + await writeFile(join(projectDir, 'svelte.config.js'), ` + import adapter from '@sveltejs/adapter-cloudflare' + + export default { + kit: { + adapter: adapter() + } + } + `) + await writeFile(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'docs-worker', + compatibilityDate: '2025-01-07', + files: { + fetch: 'src/fetch.ts' + }, + assets: { + binding: 'STATIC', + directory: 'static' + } + } + `) + + const config = await loadConfig({ cwd: projectDir }) + + expect(config.files?.fetch).toBe('src/fetch.ts') + expect(config.assets).toEqual({ + binding: 'STATIC', + directory: 'static' + }) + }) + + test('loads workspace-root .env values before evaluating nested configs', async () => { + const workspaceDir = join(TEST_DIR, 'workspace-root') + const projectDir = join(workspaceDir, 'apps/docs') + await mkdir(projectDir, { recursive: true }) + + await writeFile(join(workspaceDir, 'package.json'), JSON.stringify({ + name: 'workspace-root', + private: true, + workspaces: ['apps/*'] + }, null, 2)) + await writeFile(join(workspaceDir, '.env'), 'CLOUDFLARE_ACCOUNT_ID=workspace-account\n') + await writeFile(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'docs-worker', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2025-01-07' + } + `) + + delete process.env.CLOUDFLARE_ACCOUNT_ID + + const config = await loadConfig({ cwd: projectDir }) + + expect(config.accountId).toBe('workspace-account') + }) + + test('prefers an explicit process env account id over the workspace-root .env', async () => { + const workspaceDir = join(TEST_DIR, 'workspace-root-explicit-env') + const projectDir = join(workspaceDir, 'apps/docs') + await mkdir(projectDir, { recursive: true }) + + await writeFile(join(workspaceDir, 'package.json'), JSON.stringify({ + name: 'workspace-root', + private: true, + workspaces: ['apps/*'] + }, null, 2)) + await writeFile(join(workspaceDir, '.env'), 'CLOUDFLARE_ACCOUNT_ID=workspace-account\n') + await writeFile(join(projectDir, 'devflare.config.ts'), ` + export default { + name: 'docs-worker', + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + compatibilityDate: '2025-01-07' + } + `) + + process.env.CLOUDFLARE_ACCOUNT_ID = 'explicit-account' + + const config = await loadConfig({ cwd: projectDir }) + + expect(config.accountId).toBe('explicit-account') + }) +}) + +describe('resolveConfigPath', () => { + beforeEach(async () => { + await mkdir(TEST_DIR, { recursive: true }) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('finds devflare.config.ts', async () => { + await writeFile(join(TEST_DIR, 'devflare.config.ts'), 'export default {}') + + const result = await resolveConfigPath(TEST_DIR) + + expect(result).toContain('devflare.config.ts') + }) + + test('finds devflare.config.js', async () => { + await writeFile(join(TEST_DIR, 'devflare.config.js'), 'export default {}') + + const result = await resolveConfigPath(TEST_DIR) + + expect(result).toContain('devflare.config.js') + }) + + test('prefers .ts over .js', async () => { + await writeFile(join(TEST_DIR, 'devflare.config.ts'), 'export default {}') + await writeFile(join(TEST_DIR, 'devflare.config.js'), 'export default {}') + + const result = await resolveConfigPath(TEST_DIR) + + expect(result).toContain('.ts') + }) + + test('returns undefined when no config found', async () => { + const result = await resolveConfigPath(TEST_DIR) + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/devflare/tests/unit/config/local-dev-vars.test.ts b/packages/devflare/tests/unit/config/local-dev-vars.test.ts new file mode 100644 index 0000000..6a59848 --- /dev/null +++ b/packages/devflare/tests/unit/config/local-dev-vars.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { + applyLocalDevVarsToConfig, + loadLocalDevVars, + toWranglerSecretsConfig +} from '../../../src/config/local-dev-vars' +import type { DevflareConfig } from '../../../src/config/schema' + +const tempDirs: string[] = [] + +function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-local-vars-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'devflare.config.ts'), 'export default {}') + return dir +} + +function writeProjectFile(dir: string, name: string, contents: string): void { + writeFileSync(join(dir, name), contents) +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('loadLocalDevVars', () => { + test('loads .dev.vars ahead of .env and lets local values override config vars', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.dev.vars', [ + 'SHARED=from-dev-vars', + 'LOCAL_ONLY=secret' + ].join('\n')) + writeProjectFile(cwd, '.env', [ + 'LOCAL_ONLY=from-env', + 'ENV_ONLY=ignored' + ].join('\n')) + + const vars = await loadLocalDevVars({ + cwd, + configPath: join(cwd, 'devflare.config.ts'), + vars: { + SHARED: 'from-config', + CONFIG_ONLY: 'plain' + } + }) + + expect(vars).toEqual({ + SHARED: 'from-dev-vars', + CONFIG_ONLY: 'plain', + LOCAL_ONLY: 'secret' + }) + }) + + test('uses environment-specific .dev.vars without merging generic .dev.vars', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.dev.vars', [ + 'SHARED=generic', + 'GENERIC_ONLY=yes' + ].join('\n')) + writeProjectFile(cwd, '.dev.vars.staging', [ + 'SHARED=staging', + 'STAGING_ONLY=yes' + ].join('\n')) + + const vars = await loadLocalDevVars({ + cwd, + configPath: join(cwd, 'devflare.config.ts'), + environment: 'staging' + }) + + expect(vars).toEqual({ + SHARED: 'staging', + STAGING_ONLY: 'yes' + }) + }) + + test('merges .env files and lets the most specific environment file win', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.env', [ + 'SHARED=base', + 'BASE_ONLY=yes' + ].join('\n')) + writeProjectFile(cwd, '.env.local', [ + 'LOCAL_ONLY=yes' + ].join('\n')) + writeProjectFile(cwd, '.env.staging', [ + 'ENV_ONLY=yes' + ].join('\n')) + writeProjectFile(cwd, '.env.staging.local', [ + 'SHARED=staging-local', + 'STAGING_LOCAL_ONLY=yes' + ].join('\n')) + + const vars = await loadLocalDevVars({ + cwd, + configPath: join(cwd, 'devflare.config.ts'), + environment: 'staging' + }) + + expect(vars).toEqual({ + SHARED: 'staging-local', + BASE_ONLY: 'yes', + LOCAL_ONLY: 'yes', + ENV_ONLY: 'yes', + STAGING_LOCAL_ONLY: 'yes' + }) + }) + + test('filters local secret files to required secret declarations when configured', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.dev.vars', [ + 'API_TOKEN=secret', + 'EXTRA_SECRET=ignored' + ].join('\n')) + + const vars = await loadLocalDevVars({ + cwd, + configPath: join(cwd, 'devflare.config.ts'), + vars: { + PUBLIC_FLAG: 'on' + }, + secrets: { + API_TOKEN: { required: true } + } + }) + + expect(vars).toEqual({ + PUBLIC_FLAG: 'on', + API_TOKEN: 'secret' + }) + }) +}) + +describe('toWranglerSecretsConfig', () => { + test('converts Devflare secret declarations into Wrangler required secret names', () => { + expect(toWranglerSecretsConfig({ + API_TOKEN: { required: true }, + OPTIONAL_TOKEN: { required: false } + })).toEqual({ + required: ['API_TOKEN'] + }) + }) + + test('omits Wrangler secrets config when no required secrets are declared', () => { + expect(toWranglerSecretsConfig({ + OPTIONAL_TOKEN: { required: false } + })).toBeUndefined() + }) +}) + +describe('applyLocalDevVarsToConfig', () => { + test('returns a config copy with local dev vars merged into runtime vars', async () => { + const cwd = makeTempProject() + writeProjectFile(cwd, '.dev.vars', 'API_TOKEN=secret\n') + const config: DevflareConfig = { + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + vars: { + API_TOKEN: 'from-config', + PUBLIC_FLAG: 'on' + } + } + + const withLocalVars = await applyLocalDevVarsToConfig(config, { + cwd, + configPath: join(cwd, 'devflare.config.ts') + }) + + expect(withLocalVars).not.toBe(config) + expect(withLocalVars.vars).toEqual({ + API_TOKEN: 'secret', + PUBLIC_FLAG: 'on' + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/normalize-do-binding.test.ts b/packages/devflare/tests/unit/config/normalize-do-binding.test.ts new file mode 100644 index 0000000..bde80f0 --- /dev/null +++ b/packages/devflare/tests/unit/config/normalize-do-binding.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeDOBinding } from '../../../src/config/schema-normalization' + +describe('normalizeDOBinding', () => { + test('shorthand string form -> kind: local', () => { + const result = normalizeDOBinding('Counter') + + expect(result).toEqual({ className: 'Counter', kind: 'local' }) + expect(result.kind).toBe('local') + expect(result.scriptName).toBeUndefined() + }) + + test('object form without scriptName / __ref -> kind: local', () => { + const result = normalizeDOBinding({ className: 'Counter' }) + + expect(result.kind).toBe('local') + expect(result.scriptName).toBeUndefined() + expect(result.className).toBe('Counter') + }) + + test('object form with explicit scriptName -> kind: cross-worker', () => { + const result = normalizeDOBinding({ + className: 'Counter', + scriptName: 'other-worker' + }) + + expect(result.kind).toBe('cross-worker') + expect(result.scriptName).toBe('other-worker') + expect(result.className).toBe('Counter') + }) + + test('object form carrying a __ref marker -> kind: cross-worker', () => { + const refMarker = { name: 'other-worker' } + const result = normalizeDOBinding({ + className: 'Counter', + __ref: refMarker + } as unknown as Parameters[0]) + + expect(result.kind).toBe('cross-worker') + expect(result.__ref).toBe(refMarker) + }) +}) diff --git a/packages/devflare/tests/unit/config/preview-resources.test.ts b/packages/devflare/tests/unit/config/preview-resources.test.ts new file mode 100644 index 0000000..aa539dd --- /dev/null +++ b/packages/devflare/tests/unit/config/preview-resources.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, test } from 'bun:test' +import { preview, type DevflareConfig } from '../../../src/config' +import { + cleanupPreviewScopedResources, + collectPreviewScopedResourcePlan, + preparePreviewScopedResourcesForDeploy +} from '../../../src/config/preview-resources' + +const pv = preview.scope() + +function createPreviewScopedResourceConfig(): DevflareConfig { + return { + name: 'preview-resource-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + kv: { + CACHE: pv('cache-kv'), + SESSIONS: pv('sessions-kv') + }, + d1: { + PRIMARY_DB: pv('primary-db') + }, + r2: { + ASSETS: pv('assets-bucket') + }, + queues: { + producers: { + JOBS: pv('jobs-queue') + }, + consumers: [ + { + queue: pv('jobs-queue'), + deadLetterQueue: pv('jobs-dlq') + } + ] + }, + vectorize: { + DOCUMENT_INDEX: { + indexName: pv('document-index') + }, + SEARCH_INDEX: { + indexName: pv('search-index') + } + }, + hyperdrive: { + POSTGRES: { name: pv('testing-hyperdrive'), previewFallback: 'base' } + }, + browser: { + BROWSER: pv('browser-renderer') + }, + analyticsEngine: { + APP_ANALYTICS: { + dataset: pv('analytics-dataset') + } + } + } + } +} + +describe('preview-scoped resource lifecycle', () => { + test('collects preview-scoped resource names for a resolved preview identifier', () => { + const plan = collectPreviewScopedResourcePlan(createPreviewScopedResourceConfig(), { + environment: 'preview', + identifier: 'Feature/Search' + }) + + expect(plan.kv.map((ref) => ref.previewName)).toEqual([ + 'cache-kv-feature-search', + 'sessions-kv-feature-search' + ]) + expect(plan.d1.map((ref) => ref.previewName)).toEqual([ + 'primary-db-feature-search' + ]) + expect(plan.r2.map((ref) => ref.previewName)).toEqual([ + 'assets-bucket-feature-search' + ]) + expect(plan.queues.map((ref) => ref.previewName).sort()).toEqual([ + 'jobs-dlq-feature-search', + 'jobs-queue-feature-search' + ]) + expect(plan.vectorize.map((ref) => ref.previewName)).toEqual([ + 'document-index-feature-search', + 'search-index-feature-search' + ]) + expect(plan.hyperdrive.map((ref) => ref.previewName)).toEqual([ + 'testing-hyperdrive-feature-search' + ]) + expect(plan.browser.map((ref) => ref.previewName)).toEqual([ + 'browser-renderer-feature-search' + ]) + expect(plan.analyticsEngine.map((ref) => ref.previewName)).toEqual([ + 'analytics-dataset-feature-search' + ]) + }) + + test('keeps base preview resource names stable when preview env vars are already set', () => { + const originalPreviewBranch = process.env.DEVFLARE_PREVIEW_BRANCH + const originalPreviewIdentifier = process.env.DEVFLARE_PREVIEW_IDENTIFIER + + try { + process.env.DEVFLARE_PREVIEW_BRANCH = 'next' + process.env.DEVFLARE_PREVIEW_IDENTIFIER = 'next' + + const plan = collectPreviewScopedResourcePlan(createPreviewScopedResourceConfig(), { + environment: 'preview' + }) + + expect(plan.kv.map((ref) => ({ + baseName: ref.baseName, + previewName: ref.previewName + }))).toEqual([ + { + baseName: 'cache-kv', + previewName: 'cache-kv-next' + }, + { + baseName: 'sessions-kv', + previewName: 'sessions-kv-next' + } + ]) + expect(plan.queues.map((ref) => ref.previewName).sort()).toEqual([ + 'jobs-dlq-next', + 'jobs-queue-next' + ]) + expect(plan.hyperdrive.map((ref) => ({ + baseName: ref.baseName, + previewName: ref.previewName + }))).toEqual([ + { + baseName: 'testing-hyperdrive', + previewName: 'testing-hyperdrive-next' + } + ]) + } finally { + if (originalPreviewBranch === undefined) { + delete process.env.DEVFLARE_PREVIEW_BRANCH + } else { + process.env.DEVFLARE_PREVIEW_BRANCH = originalPreviewBranch + } + + if (originalPreviewIdentifier === undefined) { + delete process.env.DEVFLARE_PREVIEW_IDENTIFIER + } else { + process.env.DEVFLARE_PREVIEW_IDENTIFIER = originalPreviewIdentifier + } + } + }) + + test('provisions supported preview resources and falls back to the base Hyperdrive config', async () => { + const result = await preparePreviewScopedResourcesForDeploy(createPreviewScopedResourceConfig(), { + environment: 'preview', + identifier: 'pr-42', + accountId: 'account-123', + cloudflare: { + listKVNamespaces: async () => [], + createKVNamespace: async (_accountId, name) => ({ id: `kv-${name}`, name }), + listD1Databases: async () => [], + createD1Database: async (_accountId, name) => ({ id: `d1-${name}`, name, version: 'alpha' }), + listR2Buckets: async () => [], + createR2Bucket: async (_accountId, name) => ({ name, createdOn: new Date('2026-01-01T00:00:00Z') }), + listQueues: async () => [], + createQueue: async (_accountId, name) => ({ id: `queue-${name}`, name }), + listVectorizeIndexes: async () => ([ + { name: 'document-index', dimensions: 32, metric: 'cosine', description: 'documents' }, + { name: 'search-index', dimensions: 16, metric: 'euclidean', description: 'search' } + ]), + createVectorizeIndex: async (_accountId, index) => ({ + name: index.name, + dimensions: index.dimensions, + metric: index.metric, + description: index.description + }), + listHyperdrives: async () => ([ + { id: 'hyperdrive-base', name: 'testing-hyperdrive' } + ]) + } + }) + + expect(result.accountId).toBe('account-123') + expect(result.created.kv).toEqual(['cache-kv-pr-42', 'sessions-kv-pr-42']) + expect(result.created.d1).toEqual(['primary-db-pr-42']) + expect(result.created.r2).toEqual(['assets-bucket-pr-42']) + expect(result.created.queues.sort()).toEqual(['jobs-dlq-pr-42', 'jobs-queue-pr-42']) + expect(result.created.vectorize).toEqual(['document-index-pr-42', 'search-index-pr-42']) + expect(result.config.bindings?.kv?.CACHE).toBe('cache-kv-pr-42') + expect(result.config.bindings?.d1?.PRIMARY_DB).toBe('primary-db-pr-42') + expect(result.config.bindings?.r2?.ASSETS).toBe('assets-bucket-pr-42') + expect(result.config.bindings?.queues?.producers?.JOBS).toBe('jobs-queue-pr-42') + expect(result.config.bindings?.vectorize?.DOCUMENT_INDEX.indexName).toBe('document-index-pr-42') + expect(result.config.bindings?.hyperdrive?.POSTGRES).toBe('testing-hyperdrive') + expect(result.config.bindings?.analyticsEngine?.APP_ANALYTICS.dataset).toBe('analytics-dataset-pr-42') + expect(result.warnings.some((warning) => warning.includes('base Hyperdrive'))).toBe(true) + expect(result.warnings.some((warning) => warning.includes('Analytics Engine'))).toBe(true) + expect(result.warnings.some((warning) => warning.includes('Browser Rendering'))).toBe(true) + }) + + test('uses explicit previewId Hyperdrive bindings without lifecycle lookup', async () => { + const config: DevflareConfig = { + name: 'preview-hyperdrive-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + hyperdrive: { + POSTGRES: { + name: pv('testing-hyperdrive'), + previewId: 'preview-hyperdrive-id' + } + } + } + } + + const plan = collectPreviewScopedResourcePlan(config, { + environment: 'preview', + identifier: 'pr-7' + }) + expect(plan.hyperdrive).toEqual([]) + + const result = await preparePreviewScopedResourcesForDeploy(config, { + environment: 'preview', + identifier: 'pr-7', + cloudflare: { + listHyperdrives: async () => { + throw new Error('should not list Hyperdrive configs') + } + } + }) + + expect(result.config.bindings?.hyperdrive?.POSTGRES).toEqual({ + id: 'preview-hyperdrive-id' + }) + expect(result.accountId).toBeUndefined() + }) + + test('cleans up existing preview-scoped resources for the active preview identifier', async () => { + const deleted: string[] = [] + const result = await cleanupPreviewScopedResources(createPreviewScopedResourceConfig(), { + environment: 'preview', + identifier: 'pr-42', + accountId: 'account-123', + apply: true, + cloudflare: { + listKVNamespaces: async () => ([ + { id: 'kv-cache', name: 'cache-kv-pr-42' }, + { id: 'kv-sessions', name: 'sessions-kv-pr-42' } + ]), + deleteKVNamespace: async (_accountId, namespaceId) => { + deleted.push(`kv:${namespaceId}`) + }, + listD1Databases: async () => ([ + { id: 'd1-primary', name: 'primary-db-pr-42', version: 'alpha' } + ]), + deleteD1Database: async (_accountId, databaseId) => { + deleted.push(`d1:${databaseId}`) + }, + listR2Buckets: async () => ([ + { name: 'assets-bucket-pr-42', createdOn: new Date('2026-01-01T00:00:00Z') } + ]), + deleteR2Bucket: async (_accountId, bucketName) => { + deleted.push(`r2:${bucketName}`) + }, + listQueues: async () => ([ + { id: 'queue-jobs', name: 'jobs-queue-pr-42' }, + { id: 'queue-dlq', name: 'jobs-dlq-pr-42' } + ]), + deleteQueue: async (_accountId, queueId) => { + deleted.push(`queue:${queueId}`) + }, + listVectorizeIndexes: async () => ([ + { name: 'document-index-pr-42', dimensions: 32, metric: 'cosine' }, + { name: 'search-index-pr-42', dimensions: 16, metric: 'euclidean' } + ]), + deleteVectorizeIndex: async (_accountId, indexName) => { + deleted.push(`vectorize:${indexName}`) + }, + listHyperdrives: async () => ([ + { id: 'hyperdrive-preview', name: 'testing-hyperdrive-pr-42' } + ]), + deleteHyperdrive: async (_accountId, hyperdriveId) => { + deleted.push(`hyperdrive:${hyperdriveId}`) + } + } + }) + + expect(result.candidates.kv).toEqual(['cache-kv-pr-42', 'sessions-kv-pr-42']) + expect(result.candidates.d1).toEqual(['primary-db-pr-42']) + expect(result.candidates.r2).toEqual(['assets-bucket-pr-42']) + expect(result.candidates.queues.sort()).toEqual(['jobs-dlq-pr-42', 'jobs-queue-pr-42']) + expect(result.candidates.vectorize).toEqual(['document-index-pr-42', 'search-index-pr-42']) + expect(result.candidates.hyperdrive).toEqual(['testing-hyperdrive-pr-42']) + expect(result.deleted.hyperdrive).toEqual(['testing-hyperdrive-pr-42']) + expect(deleted.sort()).toEqual([ + 'd1:d1-primary', + 'hyperdrive:hyperdrive-preview', + 'kv:kv-cache', + 'kv:kv-sessions', + 'queue:queue-dlq', + 'queue:queue-jobs', + 'r2:assets-bucket-pr-42', + '"vectorize:document-index-pr-42"', + '"vectorize:search-index-pr-42"' + ].map((entry) => entry.replaceAll('"', ''))) + expect(result.warnings.some((warning) => warning.includes('Analytics Engine'))).toBe(true) + expect(result.warnings.some((warning) => warning.includes('Browser Rendering'))).toBe(true) + }) + + test('throws when a preview Hyperdrive binding has no dedicated preview and no previewFallback opt-in', async () => { + const config: DevflareConfig = { + name: 'preview-hyperdrive-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + hyperdrive: { + POSTGRES: pv('testing-hyperdrive') + } + } + } + + await expect( + preparePreviewScopedResourcesForDeploy(config, { + environment: 'preview', + identifier: 'pr-7', + accountId: 'account-123', + cloudflare: { + listHyperdrives: async () => ([ + { id: 'hyperdrive-base', name: 'testing-hyperdrive' } + ]) + } + }) + ).rejects.toThrow(/previewFallback: 'base'/) + }) +}) diff --git a/packages/devflare/tests/unit/config/preview.test.ts b/packages/devflare/tests/unit/config/preview.test.ts new file mode 100644 index 0000000..804db2a --- /dev/null +++ b/packages/devflare/tests/unit/config/preview.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + isPreviewScopedName, + materializePreviewScopedString, + preview, + resolveConfigForEnvironment, + type DevflareConfig +} from '../../../src/config' + +const previewBranchEnvKeys = [ + 'DEVFLARE_PREVIEW_IDENTIFIER', + 'DEVFLARE_PREVIEW_PR', + 'DEVFLARE_PREVIEW_BRANCH' +] as const + +const originalPreviewEnv = Object.fromEntries( + previewBranchEnvKeys.map((key) => [key, process.env[key]]) +) as Record<(typeof previewBranchEnvKeys)[number], string | undefined> + +afterEach(() => { + for (const key of previewBranchEnvKeys) { + const originalValue = originalPreviewEnv[key] + if (originalValue === undefined) { + delete process.env[key] + continue + } + + process.env[key] = originalValue + } +}) + +describe('preview.scope', () => { + test('creates opaque preview-scoped markers', () => { + const pv = preview.scope() + const cacheName = pv('cache-kv') + + expect(typeof cacheName).toBe('string') + expect(isPreviewScopedName(cacheName)).toBe(true) + expect(materializePreviewScopedString(cacheName)).toBe('cache-kv') + expect(materializePreviewScopedString(cacheName, { + environment: 'preview' + })).toBe('cache-kv-preview') + }) + + test('supports custom separators and branch sanitization', () => { + const pv = preview.scope({ separator: '--' }) + const datasetName = pv('analytics-dataset') + + expect(materializePreviewScopedString(datasetName, { + env: { + DEVFLARE_PREVIEW_BRANCH: 'Feature/TeSt-Branch' + } + })).toBe('analytics-dataset--feature-test-branch') + }) + + test('rejects empty base names early', () => { + const pv = preview.scope() + + expect(() => pv(' ')).toThrow('preview.scope(...) requires a non-empty baseName.') + }) + + test('rejects malformed preview-scoped markers with a clear error', () => { + expect(() => materializePreviewScopedString('__DEVFLARE_PREVIEW_SCOPE__:not-json')).toThrow( + 'Invalid Devflare preview-scoped value: the encoded payload is not valid JSON.' + ) + }) + + test('rejects preview-scoped markers that omit the base name', () => { + const invalidScopedName = `${'__DEVFLARE_PREVIEW_SCOPE__:'}${JSON.stringify({ separator: '-' })}` + + expect(() => materializePreviewScopedString(invalidScopedName)).toThrow( + 'Invalid Devflare preview-scoped value: the encoded payload is missing a non-empty baseName.' + ) + }) +}) + +describe('resolveConfigForEnvironment', () => { + test('materializes preview-scoped binding names for preview environments', () => { + const pv = preview.scope() + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + kv: { + CACHE: pv('cache-kv') + }, + r2: { + ASSETS: pv('assets-bucket') + }, + queues: { + producers: { + JOBS: pv('jobs-queue') + }, + consumers: [ + { + queue: pv('jobs-queue'), + deadLetterQueue: pv('jobs-dlq') + } + ] + }, + vectorize: { + DOCUMENT_INDEX: { + indexName: pv('document-index'), + remote: true + } + }, + browser: { + BROWSER: pv('browser-renderer') + }, + analyticsEngine: { + APP_ANALYTICS: { + dataset: pv('analytics-dataset') + } + } + }, + env: { + production: { + bindings: { + kv: { + CACHE: 'cache-kv-production' + } + } + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + const productionConfig = resolveConfigForEnvironment(config, 'production') + + expect(previewConfig.bindings?.kv?.CACHE).toBe('cache-kv-preview') + expect(previewConfig.bindings?.r2?.ASSETS).toBe('assets-bucket-preview') + expect(previewConfig.bindings?.queues?.producers?.JOBS).toBe('jobs-queue-preview') + expect(previewConfig.bindings?.queues?.consumers?.[0]?.queue).toBe('jobs-queue-preview') + expect(previewConfig.bindings?.queues?.consumers?.[0]?.deadLetterQueue).toBe('jobs-dlq-preview') + expect(previewConfig.bindings?.vectorize?.DOCUMENT_INDEX.indexName).toBe('document-index-preview') + expect(previewConfig.bindings?.vectorize?.DOCUMENT_INDEX.remote).toBe(true) + expect(previewConfig.bindings?.browser?.BROWSER).toBe('browser-renderer-preview') + expect(previewConfig.bindings?.analyticsEngine?.APP_ANALYTICS.dataset).toBe('analytics-dataset-preview') + + expect(productionConfig.bindings?.kv?.CACHE).toBe('cache-kv-production') + expect(productionConfig.bindings?.r2?.ASSETS).toBe('assets-bucket') + }) + + test('prefers explicit branch identifiers over the generic preview suffix', () => { + process.env.DEVFLARE_PREVIEW_BRANCH = 'Feature/Queue-Cleanup' + + const pv = preview.scope() + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + d1: { + PRIMARY_DB: pv('primary-db') + }, + hyperdrive: { + POSTGRES: pv('postgres-hyperdrive') + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + + expect(previewConfig.bindings?.d1?.PRIMARY_DB).toBe('primary-db-feature-queue-cleanup') + expect(previewConfig.bindings?.hyperdrive?.POSTGRES).toBe('postgres-hyperdrive-feature-queue-cleanup') + }) + + test('materializes hyperdrive object-form bindings while preserving previewFallback', () => { + const pv = preview.scope() + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + hyperdrive: { + POSTGRES: { name: pv('postgres-base'), previewFallback: 'base' } + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + const postgres = previewConfig.bindings?.hyperdrive?.POSTGRES as + | { name: string, previewFallback?: 'base' } + | undefined + + expect(typeof postgres?.name).toBe('string') + expect(postgres?.name).toBe('postgres-base-preview') + expect(isPreviewScopedName(postgres?.name ?? '')).toBe(false) + expect(postgres?.previewFallback).toBe('base') + }) + + test('uses explicit previewId for preview Hyperdrive object-form bindings', () => { + const pv = preview.scope() + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: [], + bindings: { + hyperdrive: { + POSTGRES: { + name: pv('postgres-base'), + previewId: 'preview-hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + + expect(previewConfig.bindings?.hyperdrive?.POSTGRES).toEqual({ + id: 'preview-hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }) + }) + + test('keeps forced compatibility flags while replacing root custom flags when an environment override provides its own list', () => { + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: ['nodejs_compat', 'nodejs_als', 'root-flag'], + env: { + preview: { + compatibilityFlags: ['nodejs_compat', 'nodejs_als', 'preview-flag'] + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + + expect(previewConfig.compatibilityFlags).toEqual([ + 'nodejs_compat', + 'nodejs_als', + 'preview-flag' + ]) + }) + + test('normalizes compatibility flags even without an environment override', () => { + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: ['url_standard'] + } + + const resolvedConfig = resolveConfigForEnvironment(config) + + expect(resolvedConfig.compatibilityFlags).toEqual([ + 'nodejs_compat', + 'nodejs_als', + 'url_standard' + ]) + }) + + test('replaces array fields while still deep-merging objects for environment overrides', () => { + const config: DevflareConfig = { + name: 'demo-worker', + compatibilityDate: '2026-04-08', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + vars: { + KEEP: 'root', + SHARED: 'root' + }, + routes: [ + { pattern: 'root.example/*', zone_name: 'example.com' } + ], + triggers: { + crons: ['0 * * * *'] + }, + migrations: [ + { tag: 'v1', new_classes: ['RootCounter'] } + ], + bindings: { + queues: { + consumers: [ + { queue: 'root-queue', deadLetterQueue: 'root-dlq' } + ] + } + }, + env: { + preview: { + vars: { + SHARED: 'preview', + ONLY: 'preview' + }, + routes: [ + { pattern: 'preview.example/*', zone_name: 'example.com' } + ], + triggers: { + crons: ['0 0 * * *'] + }, + migrations: [ + { tag: 'v2', new_classes: ['PreviewCounter'] } + ], + bindings: { + queues: { + consumers: [ + { queue: 'preview-queue' } + ] + } + } + } + } + } + + const previewConfig = resolveConfigForEnvironment(config, 'preview') + + expect(previewConfig.vars).toEqual({ + KEEP: 'root', + SHARED: 'preview', + ONLY: 'preview' + }) + expect(previewConfig.routes).toEqual([ + { pattern: 'preview.example/*', zone_name: 'example.com' } + ]) + expect(previewConfig.triggers?.crons).toEqual(['0 0 * * *']) + expect(previewConfig.migrations).toEqual([ + { tag: 'v2', new_classes: ['PreviewCounter'] } + ]) + expect(previewConfig.bindings?.queues?.consumers).toEqual([ + { queue: 'preview-queue' } + ]) + }) +}) diff --git a/packages/devflare/tests/unit/config/ref.test.ts b/packages/devflare/tests/unit/config/ref.test.ts new file mode 100644 index 0000000..4211090 --- /dev/null +++ b/packages/devflare/tests/unit/config/ref.test.ts @@ -0,0 +1,198 @@ +// ============================================================================= +// ref() Cross-Config Reference Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { ref } from '../../../src/config/ref' + +describe('ref', () => { + test('returns a lazy proxy', async () => { + const mockConfig = { + name: 'test-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => ({ default: mockConfig })) + + // configPath is available immediately + expect(result.configPath).toBeDefined() + + // Resolve the ref + await result.resolve() + + // Now name and config are available + expect(result.name).toBe('test-worker') + expect(result.config).toBe(mockConfig) + }) + + test('supports name override as first argument', async () => { + const mockConfig = { + name: 'original-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref('custom-worker', async () => ({ default: mockConfig })) + + await result.resolve() + + expect(result.name).toBe('custom-worker') + }) + + test('provides .worker accessor for service binding', async () => { + const mockConfig = { + name: 'math-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => ({ default: mockConfig })) + + // .worker is available immediately (lazy) + const binding = result.worker + expect(binding.__ref).toBe(result) + + // After resolution, service name is available + await result.resolve() + expect(binding.service).toBe('math-worker') + }) + + test('.worker can be called with entrypoint', async () => { + const mockConfig = { + name: 'math-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => ({ default: mockConfig })) + await result.resolve() + + const binding = result.worker('MathService') + expect(binding.service).toBe('math-worker') + expect(binding.entrypoint).toBe('MathService') + expect(binding.__ref).toBe(result) + }) + + test('handles direct export (no default)', async () => { + const mockConfig = { + name: 'direct-export-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => mockConfig) + await result.resolve() + + expect(result.name).toBe('direct-export-worker') + }) + + test('worker.service returns name override before resolution', () => { + const mockConfig = { + name: 'original-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref('custom-worker', async () => ({ default: mockConfig })) + + // Before resolution, should use name override + expect(result.worker.service).toBe('custom-worker') + }) + + test('worker() keeps the same ref instance for repeated access', async () => { + const mockConfig = { + name: 'math-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => ({ default: mockConfig })) + const baseBinding = result.worker + await result.resolve() + + expect(baseBinding.__ref).toBe(result) + expect(baseBinding.service).toBe('math-worker') + expect(result.worker.__ref).toBe(result) + }) + + test('extracts configPath from an arrow function with implicit import(...)', () => { + // @ts-expect-error intentionally non-existent module for configPath extraction test + const result = ref(() => import('./does-not-exist/devflare.config') as never) + expect(result.configPath).toBe('./does-not-exist/devflare.config') + }) + + test('extracts configPath from a block-body function returning import(...)', () => { + const result = ref(function load() { + // @ts-expect-error intentionally non-existent module for configPath extraction test + return import('./another-path/devflare.config') as never + }) + expect(result.configPath).toBe('./another-path/devflare.config') + }) + + test('returns pending sentinel when the import function has no import(...) call', () => { + const result = ref(async () => ({ default: { name: 'x' } }) as never) + expect(result.configPath).toBe('') + }) + + test('throws a clear error when the import specifier is a dynamic template literal', () => { + // Wrapping the template literal inside a factory prevents TypeScript/Bun + // from constant-folding `segment` into a static string literal. + const makeFn = (segment: string) => () => import(`./${segment}/devflare.config`) as never + expect(() => ref(makeFn('foo'))) + .toThrow(/template literal with an embedded expression|static string literal/) + }) + + test('UPPER_CASE prop access pre-resolution still returns a lazy DO ref', () => { + const mockConfig = { + name: 'test-worker', + compatibilityDate: '2025-01-07', + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + } + } + + const result = ref(async () => ({ default: mockConfig })) + + // Before resolve: lenient, returns a binding for any UPPER_CASE prop. + const counter = (result as unknown as Record).COUNTER + expect(counter).toBeDefined() + expect((counter as { kind: string }).kind).toBe('cross-worker') + // `in` is also lenient pre-resolution. + expect('UNKNOWN_DO' in (result as object)).toBe(true) + }) + + test('post-resolution: UPPER_CASE prop access returns the binding only when declared', async () => { + const mockConfig = { + name: 'test-worker', + compatibilityDate: '2025-01-07', + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + } + } + + const result = ref(async () => ({ default: mockConfig })) + await result.resolve() + + const counter = (result as unknown as Record).COUNTER + expect(counter).toBeDefined() + expect((counter as { className: string }).className).toBe('Counter') + + const unknown = (result as unknown as Record).UNKNOWN_DO + expect(unknown).toBeUndefined() + + // `in` post-resolution must reflect the actual declared bindings. + expect('COUNTER' in (result as object)).toBe(true) + expect('UNKNOWN_DO' in (result as object)).toBe(false) + }) + + test('post-resolution with no DO bindings: any UPPER_CASE prop is undefined', async () => { + const mockConfig = { + name: 'no-dos-worker', + compatibilityDate: '2025-01-07' + } + + const result = ref(async () => ({ default: mockConfig })) + await result.resolve() + + expect((result as unknown as Record).COUNTER).toBeUndefined() + expect('COUNTER' in (result as object)).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/config/resolve-phased.test.ts b/packages/devflare/tests/unit/config/resolve-phased.test.ts new file mode 100644 index 0000000..7d5e878 --- /dev/null +++ b/packages/devflare/tests/unit/config/resolve-phased.test.ts @@ -0,0 +1,273 @@ +// ============================================================================= +// Phase-discriminated resolver facade contract (R1 step 1) +// ============================================================================= +// Pins the behaviour of `resolveResources({ phase })` against the legacy +// per-phase helpers it delegates to. Subsequent R1 steps collapse the +// duplicated internals; this suite is the regression gate that guarantees +// the collapse is behaviour-preserving. +// ============================================================================= + +import { describe, expect, mock, test } from 'bun:test' +import { + compileBuildConfig, + compileConfig, + preview, + resolveConfigForEnvironment, + resolveResources +} from '../../../src/config' +import { + resolveConfigForLocalRuntime, + resolveConfigResources +} from '../../../src/config/resource-resolution' +import type { DevflareConfig } from '../../../src/config/schema' + +const baseFixture: DevflareConfig = { + name: 'phased-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [], + bindings: { + kv: { + CACHE: { name: 'cache-kv' }, + SESSIONS: { id: 'sessions-kv-id' } + }, + d1: { + DB: { name: 'main-db' }, + AUDIT: { id: 'audit-db-id' } + }, + hyperdrive: { + POSTGRES: { name: 'devflare-postgres' } + } + } +} + +const cloudflareMocks = () => ({ + getPrimaryAccount: mock(async () => ({ + id: 'primary-account', + name: 'Primary', + type: 'standard' + })), + getEffectiveAccountId: mock(async () => ({ + accountId: 'effective-account', + source: 'workspace' as const + })), + listKVNamespaces: mock(async () => ([ + { id: 'resolved-cache-kv-id', name: 'cache-kv' } + ])), + createKVNamespace: mock(async (_account: string, name: string) => ({ + id: `created-${name}-id`, + name + })), + listD1Databases: mock(async () => ([ + { id: 'resolved-main-db-id', name: 'main-db' } + ])), + createD1Database: mock(async (_account: string, name: string) => ({ + id: `created-${name}-id`, + name + })), + listR2Buckets: mock(async () => []), + createR2Bucket: mock(async (_account: string, name: string) => ({ name })), + listQueues: mock(async () => []), + createQueue: mock(async (_account: string, name: string) => ({ + id: `queue-${name}`, + name + })), + listHyperdrives: mock(async () => ([ + { id: 'resolved-postgres-id', name: 'devflare-postgres' } + ])), + listVectorizeIndexes: mock(async () => []) +}) + +describe('resolveResources facade', () => { + test('phase=build returns the env-merged source config (names preserved)', async () => { + const built = await resolveResources(baseFixture, { phase: 'build' }) + // Names remain symbolic; ids remain as provided. + expect(built.bindings?.kv).toEqual({ + CACHE: { name: 'cache-kv' }, + SESSIONS: { id: 'sessions-kv-id' } + }) + // Compiling as a build artefact yields the same shape as the legacy helper. + expect(compileBuildConfig(built).kv_namespaces).toEqual( + compileBuildConfig(baseFixture).kv_namespaces + ) + }) + + test('phase=local materialises name-based bindings into stable local identifiers', async () => { + const local = await resolveResources(baseFixture, { phase: 'local' }) + const wrangler = compileConfig(local) + const legacy = compileConfig(resolveConfigForLocalRuntime(baseFixture)) + expect(wrangler.kv_namespaces).toEqual(legacy.kv_namespaces) + expect(wrangler.d1_databases).toEqual(legacy.d1_databases) + expect(wrangler.hyperdrive).toEqual(legacy.hyperdrive) + }) + + test('phase=deploy without provision routes through read-only materialization', async () => { + const cloudflare = cloudflareMocks() + const resolved = await resolveResources(baseFixture, { + phase: 'deploy', + cloudflare + }) + + // Same ids as the legacy materialization helper on the same mocks. + expect(resolved.bindings?.kv).toEqual({ + CACHE: { id: 'resolved-cache-kv-id' }, + SESSIONS: { id: 'sessions-kv-id' } + }) + expect(resolved.bindings?.d1).toEqual({ + DB: { id: 'resolved-main-db-id' }, + AUDIT: { id: 'audit-db-id' } + }) + expect(resolved.bindings?.hyperdrive).toEqual({ + POSTGRES: { id: 'resolved-postgres-id' } + }) + // No create calls happened on the read-only path. + expect(cloudflare.createKVNamespace).toHaveBeenCalledTimes(0) + expect(cloudflare.createD1Database).toHaveBeenCalledTimes(0) + }) + + test('environment overrides apply before phase resolution', async () => { + const fixtureWithEnv: DevflareConfig = { + ...baseFixture, + env: { + production: { + bindings: { + kv: { + CACHE: { name: 'cache-kv-prod' } + } + } + } + } + } + + const built = await resolveResources(fixtureWithEnv, { + phase: 'build', + environment: 'production' + }) + expect((built.bindings?.kv as Record | undefined)?.CACHE).toEqual({ + name: 'cache-kv-prod' + }) + }) + + // C2 prep โ€” guarantee the seam is a strict superset of the legacy entry + // points by always materialising preview-scoped values, regardless of phase. + describe('C2 superset equivalence with legacy entry points', () => { + const pv = preview.scope() + const previewFixture: DevflareConfig = { + name: 'preview-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [], + bindings: { + kv: { + CACHE: pv('cache-kv') + } + } + } + + test('phase=build materialises preview-scoped names like resolveConfigForEnvironment', async () => { + const seam = await resolveResources(previewFixture, { + phase: 'build', + environment: 'preview', + preview: { identifier: 'pr-123' } + }) + const legacy = resolveConfigForEnvironment(previewFixture, 'preview') + // Seam materialises (with explicit identifier); legacy materialises + // without identifier so falls back to environment-as-suffix. Both + // must yield concrete strings, not preview markers. + const seamCache = (seam.bindings?.kv as Record | undefined)?.CACHE + const legacyCache = (legacy.bindings?.kv as Record | undefined)?.CACHE + expect(seamCache).toBe('cache-kv-pr-123') + expect(legacyCache).toBe('cache-kv-preview') + // Without explicit preview opts, seam matches legacy exactly. + const seamNoPreview = await resolveResources(previewFixture, { + phase: 'build', + environment: 'preview' + }) + expect(seamNoPreview.bindings?.kv).toEqual(legacy.bindings?.kv) + }) + + test('phase=local matches resolveConfigForLocalRuntime for preview-scoped fixtures', async () => { + const seam = await resolveResources(previewFixture, { phase: 'local', environment: 'preview' }) + const legacy = resolveConfigForLocalRuntime(previewFixture, 'preview') + expect(compileConfig(seam).kv_namespaces).toEqual(compileConfig(legacy).kv_namespaces) + }) + + test('phase=deploy matches resolveConfigResources for preview-scoped fixtures', async () => { + const cloudflare = cloudflareMocks() + cloudflare.listKVNamespaces = mock(async () => ([ + { id: 'resolved-cache-preview-id', name: 'cache-kv-preview' } + ])) as typeof cloudflare.listKVNamespaces + const seam = await resolveResources(previewFixture, { + phase: 'deploy', + environment: 'preview', + cloudflare + }) + + const cloudflare2 = cloudflareMocks() + cloudflare2.listKVNamespaces = mock(async () => ([ + { id: 'resolved-cache-preview-id', name: 'cache-kv-preview' } + ])) as typeof cloudflare2.listKVNamespaces + const legacy = await resolveConfigResources(previewFixture, { + environment: 'preview', + cloudflare: cloudflare2 + }) + expect(seam.bindings?.kv).toEqual(legacy.bindings?.kv) + }) + + // C2 step 1 โ€” compile/build path migration. The Vite plugin's + // `mode==='build'` branch now goes through resolveResources({phase:'build'}) + // instead of the lower-level resolveConfigForEnvironment. This pins the + // equivalence at the compiled-Wrangler-config level. + test('phase=build โ†’ compileBuildConfig matches resolveConfigForEnvironment โ†’ compileBuildConfig', async () => { + const fixtureWithEnv: DevflareConfig = { + ...baseFixture, + env: { + production: { + bindings: { + kv: { + CACHE: { name: 'cache-kv-prod' } + } + } + } + } + } + + const seamCompiled = compileBuildConfig( + await resolveResources(fixtureWithEnv, { phase: 'build', environment: 'production' }) + ) + const legacyCompiled = compileBuildConfig( + resolveConfigForEnvironment(fixtureWithEnv, 'production') + ) + expect(seamCompiled.kv_namespaces).toEqual(legacyCompiled.kv_namespaces) + expect(seamCompiled.d1_databases).toEqual(legacyCompiled.d1_databases) + expect(seamCompiled.hyperdrive).toEqual(legacyCompiled.hyperdrive) + }) + + // C2 step 2 โ€” Vite/local path migration. The Vite plugin's + // `mode==='serve'` branch and the programmatic helpers now go through + // resolveResources({phase:'local'}) instead of resolveConfigForLocalRuntime. + // This pins the equivalence at the compiled-Wrangler-config level. + test('phase=local โ†’ compileConfig matches resolveConfigForLocalRuntime โ†’ compileConfig', async () => { + const fixtureWithEnv: DevflareConfig = { + ...baseFixture, + env: { + production: { + bindings: { + kv: { + CACHE: { name: 'cache-kv-prod' } + } + } + } + } + } + + const seamCompiled = compileConfig( + await resolveResources(fixtureWithEnv, { phase: 'local', environment: 'production' }) + ) + const legacyCompiled = compileConfig( + resolveConfigForLocalRuntime(fixtureWithEnv, 'production') + ) + expect(seamCompiled.kv_namespaces).toEqual(legacyCompiled.kv_namespaces) + expect(seamCompiled.d1_databases).toEqual(legacyCompiled.d1_databases) + expect(seamCompiled.hyperdrive).toEqual(legacyCompiled.hyperdrive) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/resolver-contract.test.ts b/packages/devflare/tests/unit/config/resolver-contract.test.ts new file mode 100644 index 0000000..4124516 --- /dev/null +++ b/packages/devflare/tests/unit/config/resolver-contract.test.ts @@ -0,0 +1,246 @@ +// ============================================================================= +// Cross-phase resolver contract tests (F22 prerequisite) +// +// These tests pin the *current* resolution behavior of the same DevflareConfig +// across the three lifecycle consumers that today own duplicated resource +// resolution code: +// +// * Build phase โ€” `compileBuildConfig()` (preserves name-based bindings) +// * Dev / Vite โ€” `resolveConfigForLocalRuntime()` + `compileConfig()` +// (no Cloudflare lookup; uses local stable identifiers) +// * Deploy phase โ€” `prepareConfigResourcesForDeploy()` + `compileConfig()` +// (resolves or provisions concrete Cloudflare ids) +// +// Their behaviors are intentionally different per phase, but the *shape* of +// the result and the *invariants* must remain consistent for the same input. +// This file is the regression gate for any future shared `resolveResources()` +// extraction that unifies the three consumers (REMAINING.md F22 step 2). +// ============================================================================= + +import { describe, expect, mock, test } from 'bun:test' +import { + compileBuildConfig, + compileConfig, + prepareConfigResourcesForDeploy +} from '../../../src/config' +import { brandAsDeployConfig } from '../../../src/config/resolve-phased' +import { resolveConfigForLocalRuntime } from '../../../src/config/resource-resolution' +import type { DevflareConfig } from '../../../src/config/schema' + +const baseFixture: DevflareConfig = { + name: 'cross-phase-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [], + bindings: { + kv: { + CACHE: { name: 'cache-kv' }, + SESSIONS: { id: 'sessions-kv-id' } + }, + d1: { + DB: { name: 'main-db' }, + AUDIT: { id: 'audit-db-id' } + }, + hyperdrive: { + POSTGRES: { name: 'devflare-postgres' } + } + } +} + +const cloudflareMocks = () => ({ + getPrimaryAccount: mock(async () => ({ + id: 'primary-account', + name: 'Primary', + type: 'standard' + })), + getEffectiveAccountId: mock(async () => ({ + accountId: 'effective-account', + source: 'workspace' as const + })), + listKVNamespaces: mock(async () => ([ + { id: 'resolved-cache-kv-id', name: 'cache-kv' } + ])), + createKVNamespace: mock(async (_account: string, name: string) => ({ + id: `created-${name}-id`, + name + })), + listD1Databases: mock(async () => ([ + { id: 'resolved-main-db-id', name: 'main-db' } + ])), + createD1Database: mock(async (_account: string, name: string) => ({ + id: `created-${name}-id`, + name + })), + listR2Buckets: mock(async () => []), + createR2Bucket: mock(async (_account: string, name: string) => ({ + name, + createdOn: new Date('2026-04-26T00:00:00.000Z') + })), + listQueues: mock(async () => []), + createQueue: mock(async (_account: string, name: string) => ({ + id: `queue-${name}`, + name + })), + listHyperdrives: mock(async () => ([ + { id: 'resolved-postgres-id', name: 'devflare-postgres' } + ])), + listVectorizeIndexes: mock(async () => []) +}) + +describe('cross-phase resolver contract', () => { + test('build phase preserves name-based KV/D1/Hyperdrive bindings as Wrangler "name" entries', () => { + const wranglerConfig = compileBuildConfig(baseFixture) + + expect(wranglerConfig.kv_namespaces).toEqual([ + { binding: 'CACHE', name: 'cache-kv' }, + { binding: 'SESSIONS', id: 'sessions-kv-id' } + ]) + expect(wranglerConfig.d1_databases).toEqual([ + { binding: 'DB', database_name: 'main-db' }, + { binding: 'AUDIT', database_id: 'audit-db-id' } + ]) + expect(wranglerConfig.hyperdrive).toEqual([ + { binding: 'POSTGRES', name: 'devflare-postgres' } + ]) + }) + + test('dev/vite path uses local stable identifiers without Cloudflare lookup', () => { + const resolvedConfig = resolveConfigForLocalRuntime(baseFixture) + const wranglerConfig = compileConfig(resolvedConfig) + + // Local runtime collapses both name- and id-based bindings to a stable + // local identifier, so Miniflare/workerd can use them without auth. + expect(wranglerConfig.kv_namespaces).toEqual([ + { binding: 'CACHE', id: 'cache-kv' }, + { binding: 'SESSIONS', id: 'sessions-kv-id' } + ]) + expect(wranglerConfig.d1_databases).toEqual([ + { binding: 'DB', database_id: 'main-db' }, + { binding: 'AUDIT', database_id: 'audit-db-id' } + ]) + expect(wranglerConfig.hyperdrive).toEqual([ + { binding: 'POSTGRES', id: 'devflare-postgres' } + ]) + }) + + test('deploy phase resolves name bindings to the verified Cloudflare ids returned by the API', async () => { + const cloudflare = cloudflareMocks() + const result = await prepareConfigResourcesForDeploy(baseFixture, { cloudflare }) + + expect(result.config.bindings?.kv).toEqual({ + CACHE: { id: 'resolved-cache-kv-id' }, + SESSIONS: { id: 'sessions-kv-id' } + }) + expect(result.config.bindings?.d1).toEqual({ + DB: { id: 'resolved-main-db-id' }, + AUDIT: { id: 'audit-db-id' } + }) + expect(result.config.bindings?.hyperdrive).toEqual({ + POSTGRES: { id: 'resolved-postgres-id' } + }) + + // Cloudflare lookups are only invoked for bindings that need them. + expect(cloudflare.listKVNamespaces).toHaveBeenCalledTimes(1) + expect(cloudflare.listD1Databases).toHaveBeenCalledTimes(1) + expect(cloudflare.listHyperdrives).toHaveBeenCalledTimes(1) + // No new resources were created in this fixture. + expect(cloudflare.createKVNamespace).toHaveBeenCalledTimes(0) + expect(cloudflare.createD1Database).toHaveBeenCalledTimes(0) + }) + + test('binding key set is identical across all three phases for the same input', () => { + const buildResult = compileBuildConfig(baseFixture) + const devResult = compileConfig(resolveConfigForLocalRuntime(baseFixture)) + + const buildBindingNames = new Set([ + ...(buildResult.kv_namespaces ?? []).map((entry) => entry.binding), + ...(buildResult.d1_databases ?? []).map((entry) => entry.binding), + ...(buildResult.hyperdrive ?? []).map((entry) => entry.binding) + ]) + const devBindingNames = new Set([ + ...(devResult.kv_namespaces ?? []).map((entry) => entry.binding), + ...(devResult.d1_databases ?? []).map((entry) => entry.binding), + ...(devResult.hyperdrive ?? []).map((entry) => entry.binding) + ]) + + expect([...buildBindingNames].sort()).toEqual([...devBindingNames].sort()) + expect([...buildBindingNames].sort()).toEqual(['AUDIT', 'CACHE', 'DB', 'POSTGRES', 'SESSIONS']) + }) + + test('binding key set across deploy phase matches build/dev phase for the same input', async () => { + const cloudflare = cloudflareMocks() + const deployResult = await prepareConfigResourcesForDeploy(baseFixture, { cloudflare }) + const deployWranglerConfig = compileConfig(brandAsDeployConfig(deployResult.config)) + + const deployBindingNames = new Set([ + ...(deployWranglerConfig.kv_namespaces ?? []).map((entry) => entry.binding), + ...(deployWranglerConfig.d1_databases ?? []).map((entry) => entry.binding), + ...(deployWranglerConfig.hyperdrive ?? []).map((entry) => entry.binding) + ]) + + expect([...deployBindingNames].sort()).toEqual(['AUDIT', 'CACHE', 'DB', 'POSTGRES', 'SESSIONS']) + }) + + test('build phase rejects name-only bindings if invoked through compileConfig (the non-build entrypoint)', () => { + // compileConfig() requires resolved ids and must throw on name-only + // bindings. This invariant is what lets the dev path safely call + // resolveConfigForLocalRuntime() first to materialize ids, and what + // blocks accidental misuse in callers that should be using + // compileBuildConfig() instead. + expect(() => compileConfig(baseFixture as never)).toThrow( + /must be resolved before compiling Wrangler config/ + ) + }) + + test('environment overrides apply consistently across all three phases', () => { + const fixtureWithEnv: DevflareConfig = { + ...baseFixture, + env: { + production: { + bindings: { + kv: { + CACHE: { name: 'cache-kv-prod' } + } + } + } + } + } + + const buildResult = compileBuildConfig(fixtureWithEnv, 'production') + const devResult = compileConfig(resolveConfigForLocalRuntime(fixtureWithEnv, 'production')) + + const buildCacheBinding = buildResult.kv_namespaces?.find((entry) => entry.binding === 'CACHE') + const devCacheBinding = devResult.kv_namespaces?.find((entry) => entry.binding === 'CACHE') + + // Build path keeps the environment-scoped name; dev path collapses to + // the same name as a local id. Both must pick up the override. + expect(buildCacheBinding).toEqual({ binding: 'CACHE', name: 'cache-kv-prod' }) + expect(devCacheBinding).toEqual({ binding: 'CACHE', id: 'cache-kv-prod' }) + }) + + test('configs without any name-based bindings round-trip cleanly through every phase', async () => { + const idOnlyFixture: DevflareConfig = { + ...baseFixture, + bindings: { + kv: { CACHE: { id: 'cache-kv-id' } }, + d1: { DB: { id: 'db-id' } } + } + } + + const buildResult = compileBuildConfig(idOnlyFixture) + const devResult = compileConfig(resolveConfigForLocalRuntime(idOnlyFixture)) + + const cloudflare = cloudflareMocks() + const deployResult = await prepareConfigResourcesForDeploy(idOnlyFixture, { cloudflare }) + + // All three phases produce identical id-shaped Wrangler bindings, and + // the deploy phase makes no Cloudflare calls because nothing needs + // resolving. + expect(buildResult.kv_namespaces).toEqual([{ binding: 'CACHE', id: 'cache-kv-id' }]) + expect(devResult.kv_namespaces).toEqual([{ binding: 'CACHE', id: 'cache-kv-id' }]) + expect(deployResult.config.bindings?.kv).toEqual({ CACHE: { id: 'cache-kv-id' } }) + + expect(cloudflare.listKVNamespaces).toHaveBeenCalledTimes(0) + expect(cloudflare.listD1Databases).toHaveBeenCalledTimes(0) + expect(cloudflare.listHyperdrives).toHaveBeenCalledTimes(0) + }) +}) diff --git a/packages/devflare/tests/unit/config/resource-resolution.test.ts b/packages/devflare/tests/unit/config/resource-resolution.test.ts new file mode 100644 index 0000000..27f7ca3 --- /dev/null +++ b/packages/devflare/tests/unit/config/resource-resolution.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { + loadResolvedConfig, + prepareConfigResourcesForDeploy +} from '../../../src/config' +import { + resolveConfigForLocalRuntime, + resolveConfigResources +} from '../../../src/config/resource-resolution' +import type { DevflareConfig } from '../../../src/config/schema' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('config resource resolution', () => { + const baseConfig: DevflareConfig = { + name: 'resource-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] + } + + test('normalizes KV, D1, and Hyperdrive name bindings for local runtime without Cloudflare lookup', () => { + const result = resolveConfigForLocalRuntime({ + ...baseConfig, + bindings: { + kv: { + CACHE: { name: 'cache-kv' }, + SESSIONS: { id: 'sessions-kv-id' }, + REPORTING_CACHE: 'reporting-cache-kv' + }, + d1: { + DB: { name: 'main-db' }, + AUDIT: { id: 'audit-db-id' }, + REPORTING: 'reporting-db' + }, + hyperdrive: { + POSTGRES: { + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + REPLICA: { id: 'replica-hyperdrive-id' }, + REPORTING_POSTGRES: 'reporting-postgres' + } + } + }) + + expect(result.bindings?.kv).toEqual({ + CACHE: { id: 'cache-kv' }, + SESSIONS: { id: 'sessions-kv-id' }, + REPORTING_CACHE: { id: 'reporting-cache-kv' } + }) + expect(result.bindings?.d1).toEqual({ + DB: { id: 'main-db' }, + AUDIT: { id: 'audit-db-id' }, + REPORTING: { id: 'reporting-db' } + }) + expect(result.bindings?.hyperdrive).toEqual({ + POSTGRES: { + id: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + REPLICA: { id: 'replica-hyperdrive-id' }, + REPORTING_POSTGRES: { id: 'reporting-postgres' } + }) + }) + + test('resolves KV, D1, and Hyperdrive name bindings using Cloudflare resource lookup', async () => { + const getPrimaryAccount = mock(async () => ({ + id: 'primary-account', + name: 'Primary', + type: 'standard' + })) + const getEffectiveAccountId = mock(async () => ({ + accountId: 'effective-account', + source: 'workspace' as const + })) + const listKVNamespaces = mock(async () => ([ + { id: 'resolved-cache-kv-id', name: 'cache-kv' }, + { id: 'reporting-cache-kv-id', name: 'reporting-cache-kv' }, + { id: 'sessions-kv-id', name: 'sessions-kv' } + ])) + const listD1Databases = mock(async () => ([ + { id: 'resolved-db-id', name: 'main-db' }, + { id: 'analytics-db-id', name: 'analytics-db' }, + { id: 'reporting-db-id', name: 'reporting-db' } + ])) + const listHyperdrives = mock(async () => ([ + { id: 'resolved-postgres-id', name: 'devflare-testing' }, + { id: 'reporting-postgres-id', name: 'reporting-postgres' }, + { id: 'replica-hyperdrive-id', name: 'replica-postgres' } + ])) + + const result = await resolveConfigResources({ + ...baseConfig, + bindings: { + kv: { + CACHE: { name: 'cache-kv' }, + REPORTING_CACHE: 'reporting-cache-kv', + SESSIONS: { id: 'sessions-kv-id' } + }, + d1: { + DB: { name: 'main-db' }, + ANALYTICS: { id: 'analytics-db-id' }, + REPORTING: 'reporting-db' + }, + hyperdrive: { + POSTGRES: { + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + REPORTING_POSTGRES: 'reporting-postgres', + REPLICA: { id: 'replica-hyperdrive-id' } + }, + r2: { + ASSETS: 'assets-bucket' + } + } + }, { + cloudflare: { + getPrimaryAccount, + getEffectiveAccountId, + listKVNamespaces, + listD1Databases, + listHyperdrives + } + }) + + expect(result.bindings?.kv).toEqual({ + CACHE: { id: 'resolved-cache-kv-id' }, + REPORTING_CACHE: { id: 'reporting-cache-kv-id' }, + SESSIONS: { id: 'sessions-kv-id' } + }) + expect(result.bindings?.d1).toEqual({ + DB: { id: 'resolved-db-id' }, + ANALYTICS: { id: 'analytics-db-id' }, + REPORTING: { id: 'reporting-db-id' } + }) + expect(result.bindings?.hyperdrive).toEqual({ + POSTGRES: { + id: 'resolved-postgres-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + REPORTING_POSTGRES: { id: 'reporting-postgres-id' }, + REPLICA: { id: 'replica-hyperdrive-id' } + }) + expect(result.bindings?.r2).toEqual({ + ASSETS: 'assets-bucket' + }) + expect(getPrimaryAccount).toHaveBeenCalledTimes(1) + expect(getEffectiveAccountId).toHaveBeenCalledWith('primary-account') + expect(listKVNamespaces).toHaveBeenCalledWith('effective-account') + expect(listD1Databases).toHaveBeenCalledWith('effective-account') + expect(listHyperdrives).toHaveBeenCalledWith('effective-account') + }) + + test('prefers explicit accountId when resolving KV, D1, and Hyperdrive names', async () => { + const getPrimaryAccount = mock(async () => { + throw new Error('should not need primary account lookup') + }) + const listKVNamespaces = mock(async (accountId: string) => { + expect(accountId).toBe('config-account') + return [{ id: 'resolved-cache-kv-id', name: 'cache-kv' }] + }) + const listD1Databases = mock(async (accountId: string) => { + expect(accountId).toBe('config-account') + return [{ id: 'resolved-db-id', name: 'main-db' }] + }) + const listHyperdrives = mock(async (accountId: string) => { + expect(accountId).toBe('config-account') + return [{ id: 'resolved-postgres-id', name: 'devflare-testing' }] + }) + + const result = await resolveConfigResources({ + ...baseConfig, + accountId: 'config-account', + bindings: { + kv: { + CACHE: { name: 'cache-kv' } + }, + d1: { + DB: { name: 'main-db' } + }, + hyperdrive: { + POSTGRES: { name: 'devflare-testing' } + } + } + }, { + cloudflare: { + getPrimaryAccount, + listKVNamespaces, + listD1Databases, + listHyperdrives + } + }) + + expect(result.bindings?.kv).toEqual({ CACHE: { id: 'resolved-cache-kv-id' } }) + expect(result.bindings?.d1).toEqual({ DB: { id: 'resolved-db-id' } }) + expect(result.bindings?.hyperdrive).toEqual({ POSTGRES: { id: 'resolved-postgres-id' } }) + expect(getPrimaryAccount).not.toHaveBeenCalled() + }) + + test('throws a helpful error when a named KV namespace cannot be found', async () => { + await expect(resolveConfigResources({ + ...baseConfig, + bindings: { + kv: { + CACHE: { name: 'missing-cache-kv' } + } + } + }, { + cloudflare: { + getPrimaryAccount: async () => ({ id: 'primary-account', name: 'Primary', type: 'standard' }), + getEffectiveAccountId: async () => ({ accountId: 'effective-account', source: 'workspace' as const }), + listKVNamespaces: async () => [{ id: 'resolved-cache-kv-id', name: 'cache-kv' }], + listD1Databases: async () => [] + } + })).rejects.toThrow('Could not find KV namespace(s) for CACHE โ†’ missing-cache-kv') + }) + + test('throws a helpful error when a named D1 database cannot be found', async () => { + await expect(resolveConfigResources({ + ...baseConfig, + bindings: { + d1: { + DB: { name: 'missing-db' } + } + } + }, { + cloudflare: { + getPrimaryAccount: async () => ({ id: 'primary-account', name: 'Primary', type: 'standard' }), + getEffectiveAccountId: async () => ({ accountId: 'effective-account', source: 'workspace' as const }), + listD1Databases: async () => [{ id: 'resolved-db-id', name: 'main-db' }] + } + })).rejects.toThrow('Could not find D1 database(s) for DB โ†’ missing-db') + }) + + test('throws a helpful error when a named Hyperdrive configuration cannot be found', async () => { + await expect(resolveConfigResources({ + ...baseConfig, + bindings: { + hyperdrive: { + POSTGRES: { name: 'missing-hyperdrive' } + } + } + }, { + cloudflare: { + getPrimaryAccount: async () => ({ id: 'primary-account', name: 'Primary', type: 'standard' }), + getEffectiveAccountId: async () => ({ accountId: 'effective-account', source: 'workspace' as const }), + listKVNamespaces: async () => [], + listD1Databases: async () => [], + listHyperdrives: async () => [{ id: 'resolved-postgres-id', name: 'devflare-testing' }] + } + })).rejects.toThrow('Could not find Hyperdrive configuration(s) for POSTGRES โ†’ missing-hyperdrive') + }) + + test('loads config from disk and resolves KV, D1, and Hyperdrive name bindings', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-resolved-config-')) + tempDirs.push(projectDir) + + await writeFile(join(projectDir, 'devflare.config.ts'), ` +export default { + name: 'resolved-worker', + compatibilityDate: '2025-01-07', + bindings: { + kv: { + CACHE: { name: 'cache-kv' } + }, + d1: { + DB: { name: 'main-db' } + }, + hyperdrive: { + POSTGRES: 'devflare-testing' + }, + r2: { + ASSETS: 'assets-bucket' + } + } +} + `.trim()) + + const result = await loadResolvedConfig({ + cwd: projectDir, + cloudflare: { + getPrimaryAccount: async () => ({ id: 'primary-account', name: 'Primary', type: 'standard' }), + getEffectiveAccountId: async () => ({ accountId: 'effective-account', source: 'workspace' as const }), + listKVNamespaces: async () => [{ id: 'resolved-cache-kv-id', name: 'cache-kv' }], + listD1Databases: async () => [{ id: 'resolved-db-id', name: 'main-db' }], + listHyperdrives: async () => [{ id: 'resolved-postgres-id', name: 'devflare-testing' }] + } + }) + + expect(result.name).toBe('resolved-worker') + expect(result.bindings?.kv).toEqual({ CACHE: { id: 'resolved-cache-kv-id' } }) + expect(result.bindings?.d1).toEqual({ DB: { id: 'resolved-db-id' } }) + expect(result.bindings?.hyperdrive).toEqual({ POSTGRES: { id: 'resolved-postgres-id' } }) + expect(result.bindings?.r2).toEqual({ ASSETS: 'assets-bucket' }) + }) + + test('prepares deploy resources by provisioning missing KV and D1 names', async () => { + const result = await prepareConfigResourcesForDeploy({ + ...baseConfig, + accountId: 'config-account', + bindings: { + kv: { + CACHE: { name: 'cache-kv' } + }, + d1: { + DB: { name: 'main-db' } + } + } + }, { + cloudflare: { + listKVNamespaces: async () => [], + createKVNamespace: async (_accountId, resourceName) => ({ + id: `kv-${resourceName}`, + name: resourceName + }), + listD1Databases: async () => [], + createD1Database: async (_accountId, resourceName) => ({ + id: `d1-${resourceName}`, + name: resourceName, + version: 'alpha' + }) + } + }) + + expect(result.config.bindings?.kv).toEqual({ + CACHE: { id: 'kv-cache-kv' } + }) + expect(result.config.bindings?.d1).toEqual({ + DB: { id: 'd1-main-db' } + }) + expect(result.created.kv).toEqual(['cache-kv']) + expect(result.created.d1).toEqual(['main-db']) + }) +}) diff --git a/packages/devflare/tests/unit/config/schema-bindings.test.ts b/packages/devflare/tests/unit/config/schema-bindings.test.ts new file mode 100644 index 0000000..df48433 --- /dev/null +++ b/packages/devflare/tests/unit/config/schema-bindings.test.ts @@ -0,0 +1,2 @@ +import './schema-bindings/bindings' +import './schema-bindings/runtime-config' diff --git a/packages/devflare/tests/unit/config/schema-bindings/bindings.ts b/packages/devflare/tests/unit/config/schema-bindings/bindings.ts new file mode 100644 index 0000000..cbc2240 --- /dev/null +++ b/packages/devflare/tests/unit/config/schema-bindings/bindings.ts @@ -0,0 +1,921 @@ +// ============================================================================= +// Config Schema Binding Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { configSchema } from '../../../../src/config/schema' + +describe('schema validation', () => { + describe('bindings', () => { + test('accepts KV bindings configured by string shorthand names', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + kv: { + CACHE: 'cache-kv' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.kv?.CACHE).toBe('cache-kv') + } + }) + + test('accepts KV bindings configured by name', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + kv: { + CACHE: { + name: 'cache-kv' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.kv?.CACHE).toEqual({ name: 'cache-kv' }) + } + }) + + test('accepts KV bindings configured by explicit id object', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + kv: { + CACHE: { + id: 'kv-namespace-id-123' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.kv?.CACHE).toEqual({ id: 'kv-namespace-id-123' }) + } + }) + + test('accepts D1 bindings configured by string shorthand names', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + d1: { + DB: 'app-database' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.d1?.DB).toBe('app-database') + } + }) + + test('accepts D1 bindings configured by name', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + d1: { + DB: { + name: 'main-database' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.d1?.DB).toEqual({ name: 'main-database' }) + } + }) + + test('accepts D1 bindings configured by explicit id object', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + d1: { + DB: { + id: 'd1-database-id-789' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.d1?.DB).toEqual({ id: 'd1-database-id-789' }) + } + }) + + test('accepts R2 bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + r2: { + BUCKET: 'my-bucket-name' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.r2?.BUCKET).toBe('my-bucket-name') + } + }) + + test('accepts Durable Object bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + durableObjects: { + COUNTER: { + className: 'Counter', + scriptName: 'my-worker' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + const doBinding = result.data.bindings?.durableObjects?.COUNTER + expect(typeof doBinding === 'object' && doBinding?.className).toBe('Counter') + } + }) + + test('accepts Queue bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + queues: { + producers: { + QUEUE: 'my-queue-name' + }, + consumers: [{ queue: 'my-queue-name', maxBatchSize: 10 }] + } + } + }) + + expect(result.success).toBe(true) + }) + + test('accepts files.tail and Tail Consumers configuration', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + files: { + tail: 'src/observability-tail.ts' + }, + tailConsumers: [ + 'observability-tail', + { + service: 'staging-observability-tail', + environment: 'staging' + } + ] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.files?.tail).toBe('src/observability-tail.ts') + expect(result.data.tailConsumers).toEqual([ + 'observability-tail', + { + service: 'staging-observability-tail', + environment: 'staging' + } + ]) + } + }) + + test('rejects Tail Consumers without a service name', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + tailConsumers: [{ service: '' }] + }) + + expect(result.success).toBe(false) + }) + + test('accepts Rate Limiting bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { + limit: 100, + period: 60 + } + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.rateLimits?.MY_RATE_LIMITER).toEqual({ + namespaceId: '1001', + simple: { + limit: 100, + period: 60 + } + }) + } + }) + + test('rejects Rate Limiting bindings with unsupported periods', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { + limit: 100, + period: 30 + } + } + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts Version Metadata bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.versionMetadata).toEqual({ + binding: 'CF_VERSION_METADATA' + }) + } + }) + + test('accepts Worker Loader bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + workerLoaders: { + LOADER: {} + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.workerLoaders?.LOADER).toEqual({}) + } + }) + + test('accepts mTLS Certificate bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123', + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.mtlsCertificates?.API_CERT).toEqual({ + certificateId: 'cert-123', + remote: true + }) + } + }) + + test('rejects mTLS Certificate bindings without a certificate id', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + mtlsCertificates: { + API_CERT: { + certificateId: '' + } + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts Dispatch Namespace bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers', + outbound: { + service: 'outbound-worker', + environment: 'production', + parameters: ['ctx'] + }, + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.dispatchNamespaces?.DISPATCHER).toEqual({ + namespace: 'customers', + outbound: { + service: 'outbound-worker', + environment: 'production', + parameters: ['ctx'] + }, + remote: true + }) + } + }) + + test('accepts Workflow bindings with remote and step limits', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow', + scriptName: 'workflow-worker', + remote: true, + limits: { + steps: 42 + } + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.workflows?.ORDER_WORKFLOW).toEqual({ + name: 'orders', + className: 'OrderWorkflow', + scriptName: 'workflow-worker', + remote: true, + limits: { + steps: 42 + } + }) + } + }) + + test('accepts Pipeline bindings with string and remote object forms', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + pipelines: { + EVENTS: 'events-stream', + AUDIT: { + pipeline: 'audit-stream', + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.pipelines?.EVENTS).toBe('events-stream') + expect(result.data.bindings?.pipelines?.AUDIT).toEqual({ + pipeline: 'audit-stream', + remote: true + }) + } + }) + + test('accepts one Images binding with remote option', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + images: { + IMAGES: { + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.images?.IMAGES).toEqual({ + remote: true + }) + } + }) + + test('rejects multiple Images bindings until Wrangler supports them', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + images: { + IMAGES: {}, + OTHER_IMAGES: {} + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts one Media Transformations binding with remote option', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + bindings: { + media: { + MEDIA: { + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.media?.MEDIA).toEqual({ + remote: true + }) + } + }) + + test('rejects multiple Media Transformations bindings until Wrangler supports them', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + bindings: { + media: { + MEDIA: {}, + OTHER_MEDIA: {} + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts Artifacts bindings with string and remote object forms', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + bindings: { + artifacts: { + ARTIFACTS: 'default', + ARCHIVE: { + namespace: 'archive', + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.artifacts?.ARTIFACTS).toBe('default') + expect(result.data.bindings?.artifacts?.ARCHIVE).toEqual({ + namespace: 'archive', + remote: true + }) + } + }) + + test('accepts Secrets Store bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.secretsStore?.API_TOKEN).toEqual({ + storeId: 'store-123', + secretName: 'api-token' + }) + } + }) + + test('accepts Secrets Store shorthand when a default store id is configured', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.secretsStoreId).toBe('store-123') + expect(result.data.bindings?.secretsStore?.API_TOKEN).toBe('api-token') + } + }) + + test('accepts environment Secrets Store shorthand inherited from the worker default store id', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + secretsStoreId: 'store-123', + env: { + preview: { + bindings: { + secretsStore: { + API_TOKEN: 'preview-api-token' + } + } + } + } + }) + + expect(result.success).toBe(true) + }) + + test('rejects Secrets Store shorthand without a default store id', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('secretsStoreId') + } + }) + + test('rejects environment Secrets Store shorthand without a default store id', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + env: { + preview: { + bindings: { + secretsStore: { + API_TOKEN: 'preview-api-token' + } + } + } + } + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual([ + 'env', + 'preview', + 'bindings', + 'secretsStore', + 'API_TOKEN' + ]) + } + }) + + test('rejects Secrets Store bindings without a store id', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + secretsStore: { + API_TOKEN: { + storeId: '', + secretName: 'api-token' + } + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts Service bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + services: { + AUTH: { service: 'auth-worker' }, + ADMIN: { + service: 'auth-worker', + environment: 'production', + entrypoint: 'AdminEntrypoint' + } + } + } + }) + + expect(result.success).toBe(true) + }) + + test('rejects malformed Service binding entrypoints and unknown keys', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + services: { + AUTH: { + service: 'auth-worker', + entrypoint: 123, + unsafe: true + } + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts AI binding with local-development flags', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + ai: { + binding: 'AI', + remote: true, + staging: true + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.ai).toEqual({ + binding: 'AI', + remote: true, + staging: true + }) + } + }) + + test('accepts AI Search namespace and instance bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + bindings: { + aiSearchNamespaces: { + AI_SEARCH: { + namespace: 'default', + remote: true + } + }, + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs', + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.aiSearchNamespaces?.AI_SEARCH).toEqual({ + namespace: 'default', + remote: true + }) + expect(result.data.bindings?.aiSearch?.DOCS_SEARCH).toEqual({ + instanceName: 'docs', + remote: true + }) + } + }) + + test('accepts Vectorize bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + vectorize: { + VECTOR_INDEX: { indexName: 'my-index', remote: true } + } + } + }) + + expect(result.success).toBe(true) + }) + + test('accepts Hyperdrive bindings configured by string shorthand names', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + hyperdrive: { + POSTGRES: 'devflare-testing' + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.hyperdrive?.POSTGRES).toBe('devflare-testing') + } + }) + + test('accepts Hyperdrive bindings configured by name', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + hyperdrive: { + POSTGRES: { + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.hyperdrive?.POSTGRES).toEqual({ + name: 'devflare-testing', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }) + } + }) + + test('accepts Hyperdrive bindings configured by explicit id object', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + hyperdrive: { + POSTGRES: { + id: 'hyperdrive-config-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + }) + + expect(result.success).toBe(true) + }) + + test('accepts Browser binding map syntax', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + browser: { BROWSER: 'browser-resource' } + } + }) + + expect(result.success).toBe(true) + }) + + test('accepts Browser binding object form with remote option', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + bindings: { + browser: { + BROWSER: { + remote: true + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.browser?.BROWSER).toEqual({ + remote: true + }) + } + }) + + test('rejects multiple Browser bindings until Wrangler supports them', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + browser: { + BROWSER_ONE: 'browser-one', + BROWSER_TWO: 'browser-two' + } + } + }) + + expect(result.success).toBe(false) + if (!result.success) { + const browserIssue = result.error.issues.find((issue) => issue.path.includes('browser')) + expect(browserIssue?.message).toContain('exactly one browser binding') + expect(browserIssue?.message).toContain('BROWSER_ONE') + expect(browserIssue?.message).toContain('BROWSER_TWO') + } + }) + + test('accepts Analytics Engine bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + analyticsEngine: { + ANALYTICS: { dataset: 'my-dataset' } + } + } + }) + + expect(result.success).toBe(true) + }) + + test('accepts sendEmail bindings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'admin@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.bindings?.sendEmail?.EMAIL.destinationAddress).toBe('admin@example.com') + expect(result.data.bindings?.sendEmail?.EMAIL.allowedSenderAddresses).toEqual([ + 'sender@example.com' + ]) + } + }) + + test('rejects sendEmail bindings that mix destinationAddress and allowedDestinationAddresses', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + bindings: { + sendEmail: { + EMAIL: { + destinationAddress: 'admin@example.com', + allowedDestinationAddresses: ['ops@example.com'] + } + } + } + }) + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/schema-bindings/runtime-config.ts b/packages/devflare/tests/unit/config/schema-bindings/runtime-config.ts new file mode 100644 index 0000000..1793b3a --- /dev/null +++ b/packages/devflare/tests/unit/config/schema-bindings/runtime-config.ts @@ -0,0 +1,205 @@ +// ============================================================================= +// Config Schema Binding Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' + +import { configSchema } from '../../../../src/config/schema' + +describe('schema validation', () => { + describe('runtime config', () => { + test('accepts expanded Static Assets routing options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + assets: { + directory: './public', + binding: 'ASSETS', + html_handling: 'force-trailing-slash', + not_found_handling: 'single-page-application', + run_worker_first: ['/api/*', '!/api/docs/*'] + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.assets).toEqual({ + directory: './public', + binding: 'ASSETS', + html_handling: 'force-trailing-slash', + not_found_handling: 'single-page-application', + run_worker_first: ['/api/*', '!/api/docs/*'] + }) + } + }) + + test('rejects unsupported Static Assets routing options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + assets: { + directory: './public', + html_handling: 'sometimes' + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts expanded Observability logs and traces options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + observability: { + enabled: true, + head_sampling_rate: 0.5, + logs: { + enabled: true, + head_sampling_rate: 0.25, + invocation_logs: false, + persist: false, + destinations: ['workers_logs'] + }, + traces: { + enabled: true, + head_sampling_rate: 0.1, + persist: true, + destinations: ['cloudflare'] + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.observability).toEqual({ + enabled: true, + head_sampling_rate: 0.5, + logs: { + enabled: true, + head_sampling_rate: 0.25, + invocation_logs: false, + persist: false, + destinations: ['workers_logs'] + }, + traces: { + enabled: true, + head_sampling_rate: 0.1, + persist: true, + destinations: ['cloudflare'] + } + }) + } + }) + + test('rejects invalid nested Observability sampling rates', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + observability: { + logs: { + head_sampling_rate: 1.5 + } + } + }) + + expect(result.success).toBe(false) + }) + + test('rejects unsupported nested Observability options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + observability: { + traces: { + unsupported: true + } + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts CPU and subrequest limits', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + limits: { + cpu_ms: 100, + subrequests: 150 + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limits).toEqual({ + cpu_ms: 100, + subrequests: 150 + }) + } + }) + + test('rejects unsupported limit options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + limits: { + memory_mb: 256 + } + }) + + expect(result.success).toBe(false) + }) + + test('accepts Smart Placement and explicit placement hints', () => { + const smart = configSchema.safeParse({ + name: 'smart-worker', + compatibilityDate: '2026-04-26', + placement: { + mode: 'smart' + } + }) + const region = configSchema.safeParse({ + name: 'region-worker', + compatibilityDate: '2026-04-26', + placement: { + region: 'aws:us-east-1' + } + }) + const host = configSchema.safeParse({ + name: 'host-worker', + compatibilityDate: '2026-04-26', + placement: { + host: 'db.example.com:5432' + } + }) + const hostname = configSchema.safeParse({ + name: 'hostname-worker', + compatibilityDate: '2026-04-26', + placement: { + hostname: 'api.example.com' + } + }) + + expect(smart.success).toBe(true) + expect(region.success).toBe(true) + expect(host.success).toBe(true) + expect(hostname.success).toBe(true) + if (region.success) { + expect(region.data.placement).toEqual({ region: 'aws:us-east-1' }) + } + }) + + test('rejects mixed Placement hint formats', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + placement: { + mode: 'smart', + region: 'aws:us-east-1' + } + }) + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/schema-core.test.ts b/packages/devflare/tests/unit/config/schema-core.test.ts new file mode 100644 index 0000000..5ee14d1 --- /dev/null +++ b/packages/devflare/tests/unit/config/schema-core.test.ts @@ -0,0 +1,473 @@ +// ============================================================================= +// Config Schema Core Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { configSchema } from '../../../src/config/schema' + +describe('configSchema', () => { + describe('minimal config', () => { + test('validates minimal valid config', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.name).toBe('my-worker') + expect(result.data.compatibilityDate).toBe('2025-01-07') + } + }) + + test('requires name', () => { + const result = configSchema.safeParse({ + compatibilityDate: '2025-01-07' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('name') + } + }) + + test('defaults compatibilityDate to current date when not provided', () => { + const result = configSchema.safeParse({ + name: 'my-worker' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.compatibilityDate).toMatch(/^\d{4}-\d{2}-\d{2}$/) + } + }) + + test('validates compatibilityDate format (YYYY-MM-DD)', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: 'invalid-date' + }) + + expect(result.success).toBe(false) + }) + }) + + describe('compatibility flags', () => { + test('merges user flags with forced flags', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: ['nodejs_compat_v2'] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.compatibilityFlags).toContain('nodejs_compat') + expect(result.data.compatibilityFlags).toContain('nodejs_als') + expect(result.data.compatibilityFlags).toContain('nodejs_compat_v2') + } + }) + + test('includes forced flags even when not provided', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.compatibilityFlags).toContain('nodejs_compat') + expect(result.data.compatibilityFlags).toContain('nodejs_als') + } + }) + }) + + describe('preview behavior', () => { + test('accepts preview cron settings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + previews: { + includeCrons: true + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.previews?.includeCrons).toBe(true) + } + }) + + test('defaults preview cron inclusion to false when previews is present without overrides', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + previews: {} + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.previews?.includeCrons).toBe(false) + } + }) + }) + + describe('file handlers', () => { + test('accepts file handler paths', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + files: { + fetch: './src/fetch.ts', + queue: './src/queue.ts', + scheduled: './src/scheduled.ts' + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.files?.fetch).toBe('./src/fetch.ts') + } + }) + + test('accepts false to disable handler', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + files: { + fetch: false + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.files?.fetch).toBe(false) + } + }) + + test('accepts routes config object', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + files: { + routes: { dir: './src/routes', prefix: '/api' } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + const routes = result.data.files?.routes + expect(routes).toBeDefined() + if (routes) { + expect(routes.dir).toBe('./src/routes') + expect(routes.prefix).toBe('/api') + } + } + }) + + test('accepts null transport to disable transport autodiscovery', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + files: { + transport: null + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.files?.transport).toBeNull() + } + }) + }) + + describe('triggers', () => { + test('accepts cron triggers', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + triggers: { + crons: ['0 * * * *', '0 0 * * *'] + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.triggers?.crons).toEqual(['0 * * * *', '0 0 * * *']) + } + }) + }) + + describe('vars and secrets', () => { + test('accepts vars', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + vars: { + API_URL: 'https://api.example.com', + DEBUG: 'true' + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.vars?.API_URL).toBe('https://api.example.com') + } + }) + + test('accepts secrets config', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + secrets: { + API_KEY: { required: true }, + OPTIONAL_KEY: { required: false } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.secrets?.API_KEY.required).toBe(true) + } + }) + }) + + describe('runtime config', () => { + test('accepts assets configuration', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + assets: { + directory: './public', + binding: 'ASSETS' + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.assets?.directory).toBe('./public') + } + }) + + test('accepts routes array', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + routes: [ + { pattern: 'example.com/*', zone_name: 'example.com' }, + { pattern: 'api.example.com', custom_domain: true } + ] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.routes?.[0].pattern).toBe('example.com/*') + } + }) + + test('rejects wildcard or path patterns for custom domain routes', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + routes: [ + { pattern: 'api.example.com/*', custom_domain: true }, + { pattern: 'app.example.com/login', custom_domain: true } + ] + }) + + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map((issue) => issue.message) + expect(messages).toContain('Wildcard operators (*) are not allowed in Custom Domains') + expect(messages).toContain('Paths are not allowed in Custom Domains') + } + }) + + test('accepts observability settings', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + observability: { + enabled: true, + head_sampling_rate: 0.1 + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.observability?.enabled).toBe(true) + } + }) + + test('accepts limits configuration', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + limits: { + cpu_ms: 50 + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limits?.cpu_ms).toBe(50) + } + }) + + test('accepts module rules for text, data, and compiled WASM assets', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + rules: [ + { type: 'Text', globs: ['**/*.txt'], fallthrough: true }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] } + ], + findAdditionalModules: true, + baseDir: './src', + preserveFileNames: true + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.rules).toEqual([ + { type: 'Text', globs: ['**/*.txt'], fallthrough: true }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] } + ]) + expect(result.data.findAdditionalModules).toBe(true) + expect(result.data.baseDir).toBe('./src') + expect(result.data.preserveFileNames).toBe(true) + } + }) + + test('rejects Python module rules so beta Python Workers remain explicit passthrough', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + rules: [ + { type: 'PythonModule', globs: ['**/*.py'] } + ] + }) + + expect(result.success).toBe(false) + }) + + test('accepts native Containers config with offline local-dev options', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + containers: [ + { + className: 'MyContainer', + image: './Dockerfile', + maxInstances: 3, + instanceType: 'lite', + imageBuildContext: '.', + imageVars: { + NODE_VERSION: '22' + }, + rolloutStepPercentage: [10, 100] + } + ] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.containers?.[0]).toEqual({ + className: 'MyContainer', + image: './Dockerfile', + maxInstances: 3, + instanceType: 'lite', + imageBuildContext: '.', + imageVars: { + NODE_VERSION: '22' + }, + rolloutStepPercentage: [10, 100] + }) + } + }) + + test('rejects Containers config without a class name', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + containers: [ + { + image: './Dockerfile' + } + ] + }) + + expect(result.success).toBe(false) + }) + + test('accepts DO migrations', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['Counter'] + }, + { + tag: 'v2', + new_classes: ['LegacyCounter'], + renamed_classes: [{ from: 'Counter', to: 'CounterV2' }], + deleted_classes: ['OldCounter'] + } + ] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.migrations?.[0].tag).toBe('v1') + expect(result.data.migrations?.[0].new_sqlite_classes).toEqual(['Counter']) + expect(result.data.migrations?.[1].deleted_classes).toEqual(['OldCounter']) + } + }) + + test('rejects unsupported DO transfer migrations instead of stripping them', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + migrations: [ + { + tag: 'v4', + transferred_classes: [ + { + from: 'OldCounter', + from_script: 'old-worker', + to: 'Counter' + } + ] + } + ] + }) + + expect(result.success).toBe(false) + }) + + test('rejects extra fields inside DO renamed_classes entries', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + migrations: [ + { + tag: 'v3', + renamed_classes: [ + { + from: 'Counter', + to: 'CounterV2', + from_script: 'old-worker' + } + ] + } + ] + }) + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/schema-env-build.test.ts b/packages/devflare/tests/unit/config/schema-env-build.test.ts new file mode 100644 index 0000000..8eb0ece --- /dev/null +++ b/packages/devflare/tests/unit/config/schema-env-build.test.ts @@ -0,0 +1,190 @@ +// ============================================================================= +// Config Schema Environment and Build Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { configSchema } from '../../../src/config/schema' + +describe('configSchema', () => { + describe('environment overrides', () => { + test('accepts environment-specific config', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + env: { + production: { + vars: { DEBUG: 'false' }, + previews: { + includeCrons: true + } + }, + staging: { + vars: { DEBUG: 'true' } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.env?.production?.vars?.DEBUG).toBe('false') + expect(result.data.env?.production?.previews?.includeCrons).toBe(true) + } + }) + + test('forces compatibility flags inside environment overrides', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + env: { + preview: { + compatibilityFlags: ['url_standard'] + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.env?.preview?.compatibilityFlags).toContain('nodejs_compat') + expect(result.data.env?.preview?.compatibilityFlags).toContain('nodejs_als') + expect(result.data.env?.preview?.compatibilityFlags).toContain('url_standard') + } + }) + + test('accepts environment-specific vite and rolldown overrides', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + env: { + preview: { + vite: { + plugins: [{ name: 'preview-plugin' }] + }, + rolldown: { + minify: true, + options: { + external: ['cloudflare:workers'] + } + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.env?.preview?.vite?.plugins).toEqual([{ name: 'preview-plugin' }]) + expect(result.data.env?.preview?.rolldown?.minify).toBe(true) + expect(result.data.env?.preview?.rolldown?.options?.external).toEqual(['cloudflare:workers']) + } + }) + + test('rejects unsupported environment-level build and plugin shorthand', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + env: { + preview: { + plugins: [{ name: 'preview-plugin' }], + build: { + minify: true, + rolldownOptions: { + external: ['cloudflare:workers'] + } + } + } + } + }) + + expect(result.success).toBe(false) + }) + }) + + describe('wrangler passthrough', () => { + test('accepts passthrough config', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + wrangler: { + passthrough: { + unsafe: { + bindings: [{ name: 'BETA', type: 'new_type' }] + } + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.wrangler?.passthrough?.unsafe).toBeDefined() + } + }) + }) + + describe('rolldown config', () => { + test('accepts canonical rolldown configuration', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + rolldown: { + target: 'esnext', + minify: true, + sourcemap: true, + options: { + external: ['cloudflare:workers'] + } + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.rolldown?.minify).toBe(true) + expect(result.data.rolldown?.options?.external).toEqual(['cloudflare:workers']) + } + }) + + test('rejects unsupported top-level build shorthand', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + build: { + target: 'esnext', + minify: true, + sourcemap: true, + rolldownOptions: { + external: ['cloudflare:workers'] + } + } + }) + + expect(result.success).toBe(false) + }) + }) + + describe('vite config', () => { + test('accepts canonical vite configuration', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + vite: { + plugins: [{ name: 'vite-plugin' }], + optInMode: 'spa' + } + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.vite?.plugins).toEqual([{ name: 'vite-plugin' }]) + expect((result.data.vite as Record | undefined)?.optInMode).toBe('spa') + } + }) + + test('rejects unsupported top-level plugins shorthand', () => { + const result = configSchema.safeParse({ + name: 'my-worker', + compatibilityDate: '2025-01-07', + plugins: [{ name: 'vite-plugin' }] + }) + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/packages/devflare/tests/unit/config/service-bindings-validation.test.ts b/packages/devflare/tests/unit/config/service-bindings-validation.test.ts new file mode 100644 index 0000000..bd12099 --- /dev/null +++ b/packages/devflare/tests/unit/config/service-bindings-validation.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import type { DevflareConfig } from '../../../src/config/schema' +import { + collectReferencedServiceNames, + ServiceBindingValidationError, + validateServiceBindings +} from '../../../src/config/service-bindings-validation' + +const fixtureWithServices: DevflareConfig = { + name: 'caller-worker', + compatibilityDate: '2025-01-07', + compatibilityFlags: [], + bindings: { + services: { + USER_API: { service: 'user-api' }, + PAYMENTS: { service: 'payments', environment: 'production' }, + SELF: { service: 'caller-worker' } + } + } +} + +describe('collectReferencedServiceNames', () => { + test('returns deduplicated target worker names', () => { + const names = collectReferencedServiceNames({ + ...fixtureWithServices, + bindings: { + services: { + A: { service: 'shared' }, + B: { service: 'shared' }, + C: { service: 'other' } + } + } + }) + expect(names.sort()).toEqual(['other', 'shared']) + }) + + test('returns [] for configs without service bindings', () => { + expect(collectReferencedServiceNames({ + name: 'x', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] + })).toEqual([]) + }) +}) + +describe('validateServiceBindings', () => { + test('passes when every referenced worker exists in the account', async () => { + await validateServiceBindings(fixtureWithServices, 'acct', { + listWorkers: async () => [ + { name: 'user-api' }, + { name: 'payments' }, + { name: 'caller-worker' } + ], + selfWorkerName: 'caller-worker' + }) + }) + + test('tolerates a missing self-reference (first-deploy)', async () => { + await validateServiceBindings(fixtureWithServices, 'acct', { + listWorkers: async () => [ + { name: 'user-api' }, + { name: 'payments' } + ], + selfWorkerName: 'caller-worker' + }) + }) + + test('throws ServiceBindingValidationError listing every missing target', async () => { + const promise = validateServiceBindings(fixtureWithServices, 'acct', { + listWorkers: async () => [ + { name: 'user-api' } + ], + selfWorkerName: 'caller-worker' + }) + await expect(promise).rejects.toBeInstanceOf(ServiceBindingValidationError) + try { + await promise + } catch (error) { + const err = error as ServiceBindingValidationError + expect(err.missing).toEqual(['payments']) + expect(err.message).toContain('payments') + expect(err.message).toContain('acct') + } + }) + + test('skips listing the account when there are no referenced services', async () => { + let called = false + await validateServiceBindings({ + name: 'x', + compatibilityDate: '2025-01-07', + compatibilityFlags: [] + }, 'acct', { + listWorkers: async () => { + called = true + return [] + } + }) + expect(called).toBe(false) + }) +}) diff --git a/packages/devflare/tests/unit/config/type-docs.test.ts b/packages/devflare/tests/unit/config/type-docs.test.ts new file mode 100644 index 0000000..8a6293f --- /dev/null +++ b/packages/devflare/tests/unit/config/type-docs.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const devflareRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') +const fixturePath = resolve(devflareRoot, 'tests/unit/config/__fixtures__/define-config-docs.ts') +const schemaTypesPaths = [ + 'src/config/schema-types.ts', + 'src/config/schema-types-bindings.ts', + 'src/config/schema-types-bindings-platform.ts', + 'src/config/schema-types-bindings-resources.ts', + 'src/config/schema-types-build.ts', + 'src/config/schema-types-runtime.ts' +].map((path) => resolve(devflareRoot, path)) + +const fixtureSource = ` +import { defineConfig } from '../../../../src/config/define' + +export default defineConfig({ + name: 'docs-worker', + compatibilityDate: '2026-05-01', + files: { + fetch: 'src/fetch.ts' + }, + vars: { + APP_ENV: 'development' + }, + secrets: { + API_TOKEN: { required: true } + }, + routes: [ + { pattern: 'docs.example.com', custom_domain: true } + ] +}) +` + +function createLanguageService() { + const files = new Map([[fixturePath, fixtureSource]]) + + const compilerOptions: ts.CompilerOptions = { + allowJs: false, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + strict: true, + target: ts.ScriptTarget.ES2022, + types: [] + } + + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => compilerOptions, + getCurrentDirectory: () => devflareRoot, + getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), + getScriptFileNames: () => [fixturePath], + getScriptSnapshot(fileName) { + const normalized = resolve(fileName) + const text = files.get(normalized) ?? (existsSync(normalized) ? readFileSync(normalized, 'utf8') : undefined) + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text) + }, + getScriptVersion: () => '1', + readFile: (fileName) => { + const normalized = resolve(fileName) + return files.get(normalized) ?? (existsSync(normalized) ? readFileSync(normalized, 'utf8') : undefined) + }, + fileExists: (fileName) => { + const normalized = resolve(fileName) + return files.has(normalized) || existsSync(normalized) + } + } + + return ts.createLanguageService(host) +} + +function quickInfoDocs(identifier: string): string { + const service = createLanguageService() + const position = fixtureSource.indexOf(identifier) + expect(position).toBeGreaterThanOrEqual(0) + + const quickInfo = service.getQuickInfoAtPosition(fixturePath, position + 1) + return ts.displayPartsToString(quickInfo?.documentation ?? []) +} + +function getLeadingDocText(node: ts.Node, sourceFile: ts.SourceFile): string { + const match = node.getFullText(sourceFile).match(/\/\*\*[\s\S]*?\*\//) + return match?.[0] ?? '' +} + +function isExported(node: ts.Node): boolean { + return ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export + ? true + : false +} + +function getSchemaTypesSourceFiles(): ts.SourceFile[] { + return schemaTypesPaths.map((schemaTypesPath) => + ts.createSourceFile( + schemaTypesPath, + readFileSync(schemaTypesPath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + ) +} + +function findInterfaceMemberDoc(interfaceName: string, memberName: string): string { + for (const sourceFile of getSchemaTypesSourceFiles()) { + for (const statement of sourceFile.statements) { + if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) { + continue + } + + for (const member of statement.members) { + const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined + if (name === memberName) { + return getLeadingDocText(member, sourceFile) + } + } + } + } + + throw new Error(`Could not find ${interfaceName}.${memberName}`) +} + +describe('defineConfig IntelliSense documentation', () => { + test.each([ + ['routes', 'Cloudflare deployment routes'], + ['custom_domain', 'custom domain'], + ['fetch', 'HTTP fetch entrypoint'], + ['vars', 'Runtime variables exposed on `env` and on the typed `vars` helper'], + ['secrets', 'secret bindings'] + ])('%s exposes useful hover documentation', (identifier, expected) => { + expect(quickInfoDocs(identifier).toLowerCase()).toContain(expected.toLowerCase()) + }) + + test('public config input types and members are documented', () => { + const missingDocs: string[] = [] + + for (const sourceFile of getSchemaTypesSourceFiles()) { + for (const statement of sourceFile.statements) { + if (!isExported(statement)) { + continue + } + + if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) { + if (!getLeadingDocText(statement, sourceFile)) { + missingDocs.push(statement.name.text) + } + } + + if (!ts.isInterfaceDeclaration(statement)) { + continue + } + + for (const member of statement.members) { + if (!ts.isPropertySignature(member) && !ts.isIndexSignatureDeclaration(member)) { + continue + } + + if (ts.isPropertySignature(member) && member.name && ts.isIdentifier(member.name)) { + if (!getLeadingDocText(member, sourceFile)) { + missingDocs.push(`${statement.name.text}.${member.name.text}`) + } + continue + } + + if (ts.isIndexSignatureDeclaration(member) && !getLeadingDocText(member, sourceFile)) { + missingDocs.push(`${statement.name.text}.[index]`) + } + } + } + } + + expect(missingDocs).toEqual([]) + }) + + test.each([ + ['DevflareConfigInput', 'compatibilityDate'], + ['PreviewConfigInput', 'includeCrons'], + ['FilesConfigInput', 'fetch'], + ['QueueConsumerInput', 'maxBatchSize'], + ['SecretConfigInput', 'required'], + ['RouteConfigInput', 'custom_domain'], + ['ServiceBindingInput', 'entrypoint'] + ])('%s.%s documents defaults and examples', (interfaceName, memberName) => { + const docs = findInterfaceMemberDoc(interfaceName, memberName) + expect(docs).toContain('@default') + expect(docs).toContain('@example') + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/d1-migrations.test.ts b/packages/devflare/tests/unit/dev-server/d1-migrations.test.ts new file mode 100644 index 0000000..ebaddfb --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/d1-migrations.test.ts @@ -0,0 +1,372 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { runD1Migrations } from '../../../src/dev-server/d1-migrations' +import { createTrackedTempDirectories } from '../../helpers/tracked-temp-directories' + +const originalFetch = globalThis.fetch +const originalSetTimeout = globalThis.setTimeout +const temporaryDirectories = createTrackedTempDirectories() + +function createProjectWithMigration(sql: string): string { + const projectDir = temporaryDirectories.create('devflare-d1-migrations-') + const migrationsDir = join(projectDir, 'migrations') + mkdirSync(migrationsDir, { recursive: true }) + writeFileSync(join(migrationsDir, '001_init.sql'), sql, 'utf-8') + return projectDir +} + +function trackTimeouts(delays: number[]): void { + globalThis.setTimeout = ((handler: Parameters[0], timeout?: number, ...args: unknown[]) => { + delays.push(Number(timeout ?? 0)) + return originalSetTimeout(handler as never, 0, ...(args as [])) + }) as typeof setTimeout +} + +afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setTimeout = originalSetTimeout + temporaryDirectories.cleanup() +}) + +describe('runD1Migrations', () => { + test('attempts the first migration request immediately before scheduling retries', async () => { + const scheduledDelays: number[] = [] + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER PRIMARY KEY);') + trackTimeouts(scheduledDelays) + + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ success: true, results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB: 'demo-db' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + expect(scheduledDelays).toEqual([]) + }) + + test('waits between retries only after a failed migration attempt', async () => { + const scheduledDelays: number[] = [] + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER PRIMARY KEY);') + trackTimeouts(scheduledDelays) + + let attempt = 0 + globalThis.fetch = mock(async () => { + attempt++ + if (attempt === 1) { + throw new Error('gateway not ready') + } + + return new Response(JSON.stringify({ success: true, results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB: 'demo-db' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(attempt).toBe(2) + expect(scheduledDelays).toEqual([500]) + }) + + test('per-binding directory wins over shared fallback; empty per-binding dir skips', async () => { + const projectDir = temporaryDirectories.create('devflare-d1-per-binding-') + const migrationsDir = join(projectDir, 'migrations') + mkdirSync(migrationsDir, { recursive: true }) + writeFileSync(join(migrationsDir, 'root.sql'), 'CREATE TABLE shared (id INTEGER);', 'utf-8') + mkdirSync(join(migrationsDir, 'DB_A'), { recursive: true }) + writeFileSync(join(migrationsDir, 'DB_A', 'a.sql'), 'CREATE TABLE a (id INTEGER);', 'utf-8') + mkdirSync(join(migrationsDir, 'DB_B'), { recursive: true }) + + const calls: Array<{ bindingName: string; statements: string[] }> = [] + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + calls.push(body) + return new Response(JSON.stringify({ success: true, results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB_A: 'db-a', + DB_B: 'db-b', + DB_C: 'db-c' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(calls).toHaveLength(2) + const byBinding = new Map(calls.map((c) => [c.bindingName, c.statements])) + expect(byBinding.get('DB_A')).toEqual(['CREATE TABLE a (id INTEGER)']) + expect(byBinding.has('DB_B')).toBe(false) + expect(byBinding.get('DB_C')).toEqual(['CREATE TABLE shared (id INTEGER)']) + }) + + test('shared fallback applies to all bindings when no per-binding dirs exist', async () => { + const projectDir = temporaryDirectories.create('devflare-d1-shared-') + const migrationsDir = join(projectDir, 'migrations') + mkdirSync(migrationsDir, { recursive: true }) + writeFileSync(join(migrationsDir, 'shared.sql'), 'CREATE TABLE shared (id INTEGER);', 'utf-8') + + const calls: Array<{ bindingName: string; statements: string[] }> = [] + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + calls.push(body) + return new Response(JSON.stringify({ success: true, results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB_ONE: 'db-one', + DB_TWO: 'db-two' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(calls).toHaveLength(2) + expect(calls[0]?.bindingName).toBe('DB_ONE') + expect(calls[0]?.statements).toEqual(['CREATE TABLE shared (id INTEGER)']) + expect(calls[1]?.bindingName).toBe('DB_TWO') + expect(calls[1]?.statements).toEqual(['CREATE TABLE shared (id INTEGER)']) + }) + + test('returns without fetching when no migrations/ directory exists', async () => { + const projectDir = temporaryDirectories.create('devflare-d1-nomig-') + const fetchMock = mock(async () => new Response('{}', { status: 200 })) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB: 'demo-db' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(fetchMock).toHaveBeenCalledTimes(0) + }) + + test('returns without fetching when config has no d1 bindings', async () => { + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER);') + const fetchMock = mock(async () => new Response('{}', { status: 200 })) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: {} + } as never, + miniflarePort: 8787 + }) + + expect(fetchMock).toHaveBeenCalledTimes(0) + }) + + test('orders SQL files alphabetically within a per-binding directory', async () => { + const projectDir = temporaryDirectories.create('devflare-d1-order-') + const migrationsDir = join(projectDir, 'migrations') + const bindingDir = join(migrationsDir, 'DB') + mkdirSync(bindingDir, { recursive: true }) + writeFileSync(join(bindingDir, '002_second.sql'), 'CREATE TABLE second (id INTEGER);', 'utf-8') + writeFileSync(join(bindingDir, '001_first.sql'), 'CREATE TABLE first (id INTEGER);', 'utf-8') + + const calls: Array<{ bindingName: string; statements: string[] }> = [] + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + calls.push(body) + return new Response(JSON.stringify({ success: true, results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { + d1: { + DB: 'demo-db' + } + } + } as never, + miniflarePort: 8787 + }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.statements).toEqual([ + 'CREATE TABLE first (id INTEGER)', + 'CREATE TABLE second (id INTEGER)' + ]) + }) + + test('ledger first-run: sends files with sha256 and marks all as applied', async () => { + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER PRIMARY KEY);') + + const calls: Array<{ bindingName: string; files?: Array<{ filename: string; sha256: string; statements: string[] }> }> = [] + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + calls.push(body) + return new Response( + JSON.stringify({ + success: true, + applied: body.files?.map((f: { filename: string }) => f.filename) ?? [], + skipped: [], + warnings: [] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { d1: { DB: 'demo-db' } } + } as never, + miniflarePort: 8787 + }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.files).toHaveLength(1) + expect(calls[0]?.files?.[0]?.filename).toBe('001_init.sql') + expect(typeof calls[0]?.files?.[0]?.sha256).toBe('string') + expect((calls[0]?.files?.[0]?.sha256 ?? '').length).toBe(64) + expect(calls[0]?.files?.[0]?.statements).toEqual(['CREATE TABLE demo (id INTEGER PRIMARY KEY)']) + }) + + test('ledger second-run with same content: gateway reports all skipped, no warnings', async () => { + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER PRIMARY KEY);') + const warnSpy = mock(() => { }) + const originalWarn = console.warn + console.warn = warnSpy as unknown as typeof console.warn + + try { + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + return new Response( + JSON.stringify({ + success: true, + applied: [], + skipped: body.files?.map((f: { filename: string }) => f.filename) ?? [], + warnings: [] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { d1: { DB: 'demo-db' } } + } as never, + miniflarePort: 8787 + }) + + expect(warnSpy).toHaveBeenCalledTimes(0) + } finally { + console.warn = originalWarn + } + }) + + test('ledger second-run with changed content: emits console.warn and does not re-apply', async () => { + const projectDir = createProjectWithMigration('CREATE TABLE demo (id INTEGER, added TEXT);') + const warnSpy = mock(() => { }) + const originalWarn = console.warn + console.warn = warnSpy as unknown as typeof console.warn + + try { + globalThis.fetch = mock(async (_input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + const filenames = body.files?.map((f: { filename: string }) => f.filename) ?? [] + return new Response( + JSON.stringify({ + success: true, + applied: [], + skipped: filenames, + warnings: filenames.map((filename: string) => ({ + filename, + message: 'sha256 drifted since last apply; skipped' + })) + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + }) as unknown as typeof fetch + + await runD1Migrations({ + cwd: projectDir, + config: { + name: 'demo-worker', + compatibilityDate: '2026-04-12', + bindings: { d1: { DB: 'demo-db' } } + } as never, + miniflarePort: 8787 + }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + const warnArgs = (warnSpy as unknown as { mock: { calls: unknown[][] } }).mock.calls[0] + expect(String(warnArgs?.[0] ?? '')).toContain('001_init.sql') + expect(String(warnArgs?.[0] ?? '')).toContain('changed') + } finally { + console.warn = originalWarn + } + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/dev-server-state.test.ts b/packages/devflare/tests/unit/dev-server/dev-server-state.test.ts new file mode 100644 index 0000000..69ca3dc --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/dev-server-state.test.ts @@ -0,0 +1,116 @@ +// ============================================================================= +// DevServerState โ€” initial-state + disposal-order regression tests (F45) +// ============================================================================= +// Pin the contract of the explicit state container that backs +// `createDevServer()`: initial null/empty handles, and the exact teardown +// ordering used by `disposeDevServerState()`. +// ============================================================================= + +import { describe, expect, mock, test } from 'bun:test' +import { createDevServerState, disposeDevServerState } from '../../../src/dev-server/dev-server-state' + +describe('createDevServerState', () => { + test('initializes every handle to its not-yet-started value', () => { + const state = createDevServerState({ enableVite: true }) + + expect(state.enableVite).toBe(true) + expect(state.miniflare).toBeNull() + expect(state.doBundler).toBeNull() + expect(state.workerSourceWatcher).toBeNull() + expect(state.workerWatchTargets).toEqual([]) + expect(state.viteProcess).toBeNull() + expect(state.config).toBeNull() + expect(state.serviceBindingResolution).toBeNull() + expect(state.browserShim).toBeNull() + expect(state.browserShimPort).toBe(8788) + expect(state.mainWorkerSurfacePaths).toEqual({ + fetch: null, + queue: null, + scheduled: null, + email: null, + tail: null + }) + expect(state.resolvedWorkerConfigPath).toBeNull() + expect(state.mainWorkerScriptPath).toBeNull() + expect(state.bundledMainWorkerScriptPath).toBeNull() + expect(state.currentDoResult).toBeNull() + expect(state.mainWorkerRoutes).toBeNull() + expect(state.generatedViteConfigPath).toBeNull() + }) + + test('respects custom browserShimPort + initial enableVite=false', () => { + const state = createDevServerState({ enableVite: false, browserShimPort: 9000 }) + expect(state.enableVite).toBe(false) + expect(state.browserShimPort).toBe(9000) + }) +}) + +describe('disposeDevServerState', () => { + test('is a no-op on a fresh state', async () => { + const state = createDevServerState({ enableVite: true }) + await expect(disposeDevServerState(state)).resolves.toBeUndefined() + expect(state.miniflare).toBeNull() + expect(state.doBundler).toBeNull() + expect(state.workerSourceWatcher).toBeNull() + expect(state.viteProcess).toBeNull() + expect(state.browserShim).toBeNull() + }) + + test('tears down in the documented order: doBundler โ†’ watcher โ†’ miniflare โ†’ vite โ†’ browserShim', async () => { + const order: string[] = [] + const state = createDevServerState({ enableVite: true }) + + state.doBundler = { + close: mock(async () => { order.push('doBundler') }) + } as unknown as typeof state.doBundler + state.workerSourceWatcher = { + close: mock(async () => { order.push('watcher') }) + } as unknown as typeof state.workerSourceWatcher + state.miniflare = { + dispose: mock(async () => { order.push('miniflare') }) + } as unknown as typeof state.miniflare + // viteProcess is killed via stopSpawnedProcessTree; we use a marker. + state.viteProcess = { __dispose: () => order.push('vite') } as unknown as typeof state.viteProcess + state.browserShim = { + stop: mock(async () => { order.push('browserShim') }) + } as unknown as typeof state.browserShim + + // Patch stopSpawnedProcessTree by intercepting its module โ€” easier: just + // rely on the fact that it will be called with our marker object. We + // can't trivially mock the import here, so instead replace viteProcess + // with a child-process-shaped stub whose `kill` records the order. + // Mock the spawned process so `stopSpawnedProcessTree` resolves + // immediately on every platform: setting `killed = true` short-circuits + // `waitForProcessExit`, and `pid = undefined` skips the win32 + // `taskkill` branch so we don't try to spawn a real child process. + const fakeProc: { killed: boolean; pid: undefined; exitCode: number; kill: (signal?: string) => void; on: () => void; once: () => void; removeListener: () => void } = { + kill: (_signal?: string) => { + order.push('vite') + fakeProc.killed = true + }, + killed: false, + pid: undefined, + exitCode: 0, + on: () => {}, + once: () => {}, + removeListener: () => {} + } + state.viteProcess = fakeProc as unknown as typeof state.viteProcess + + await disposeDevServerState(state) + + // We expect doBundler/watcher/miniflare/browserShim to be present and in order. + // vite ordering may be implementation-dependent (handled via stopSpawnedProcessTree), + // but it must come after miniflare and before browserShim. + expect(order.indexOf('doBundler')).toBeLessThan(order.indexOf('watcher')) + expect(order.indexOf('watcher')).toBeLessThan(order.indexOf('miniflare')) + expect(order.indexOf('miniflare')).toBeLessThan(order.indexOf('browserShim')) + + // All handles are cleared after disposal. + expect(state.doBundler).toBeNull() + expect(state.workerSourceWatcher).toBeNull() + expect(state.miniflare).toBeNull() + expect(state.viteProcess).toBeNull() + expect(state.browserShim).toBeNull() + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/miniflare-bindings.test.ts b/packages/devflare/tests/unit/dev-server/miniflare-bindings.test.ts new file mode 100644 index 0000000..b631a62 --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/miniflare-bindings.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + buildAiSearchInstancesConfig, + buildAiSearchNamespacesConfig, + buildHyperdrivesConfig +} from '../../../src/dev-server/miniflare-bindings' + +const hyperdriveEnvName = 'CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_POSTGRES' +const deprecatedHyperdriveEnvName = 'WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_LEGACY' +const originalHyperdriveEnv = process.env[hyperdriveEnvName] +const originalDeprecatedHyperdriveEnv = process.env[deprecatedHyperdriveEnvName] + +afterEach(() => { + if (originalHyperdriveEnv === undefined) { + delete process.env[hyperdriveEnvName] + } else { + process.env[hyperdriveEnvName] = originalHyperdriveEnv + } + + if (originalDeprecatedHyperdriveEnv === undefined) { + delete process.env[deprecatedHyperdriveEnvName] + } else { + process.env[deprecatedHyperdriveEnvName] = originalDeprecatedHyperdriveEnv + } +}) + +describe('buildHyperdrivesConfig', () => { + test('maps Hyperdrive local connection strings to Miniflare hyperdrives', () => { + const result = buildHyperdrivesConfig({ + hyperdrive: { + POSTGRES: { + name: 'app-postgres', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + }, + ANALYTICS: { + id: 'hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/analytics' + }, + REMOTE_ONLY: { + name: 'remote-only' + } + } + }) + + expect(result).toEqual({ + POSTGRES: 'postgres://user:pass@localhost:5432/app', + ANALYTICS: 'postgres://user:pass@localhost:5432/analytics' + }) + }) + + test('lets environment variables override configured Hyperdrive local connection strings', () => { + process.env[hyperdriveEnvName] = 'postgres://env:pass@localhost:5432/env' + process.env[deprecatedHyperdriveEnvName] = 'postgres://legacy:pass@localhost:5432/legacy' + + const result = buildHyperdrivesConfig({ + hyperdrive: { + POSTGRES: { + name: 'app-postgres', + localConnectionString: 'postgres://config:pass@localhost:5432/app' + }, + LEGACY: { + name: 'legacy-postgres' + } + } + }) + + expect(result).toEqual({ + POSTGRES: 'postgres://env:pass@localhost:5432/env', + LEGACY: 'postgres://legacy:pass@localhost:5432/legacy' + }) + }) +}) + +describe('AI Search Miniflare config builders', () => { + test('maps AI Search namespace and instance bindings to Miniflare config', () => { + const bindings = { + aiSearchNamespaces: { + AI_SEARCH: { + namespace: 'default', + remote: true + } + }, + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs', + remote: true + } + } + } + + expect(buildAiSearchNamespacesConfig(bindings)).toEqual({ + AI_SEARCH: { + namespace: 'default' + } + }) + expect(buildAiSearchInstancesConfig(bindings)).toEqual({ + DOCS_SEARCH: { + instance_name: 'docs' + } + }) + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/miniflare-dev-config.test.ts b/packages/devflare/tests/unit/dev-server/miniflare-dev-config.test.ts new file mode 100644 index 0000000..d9acb0b --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/miniflare-dev-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { buildMiniflareDevConfig } from '../../../src/dev-server/miniflare-dev-config' + +function buildBaseInput(overrides: Record = {}) { + return { + config: { + name: 'site-worker', + compatibilityDate: '2026-04-28', + bindings: { + services: { + VOICESTORY_API: { + service: 'voicestory-api', + entrypoint: 'VoiceStoryApi' + } + } + } + }, + cwd: 'C:/repo/site', + miniflarePort: 8787, + persist: false, + enableVite: true, + debug: false, + mainWorkerSurfacePaths: { + fetch: null, + queue: null, + scheduled: null, + email: null, + tail: null + }, + mainWorkerRoutes: null, + mainWorkerScriptPath: null, + bundledMainWorkerScriptPath: null, + workflowEntrypointScript: '', + browserShimPort: 8788, + doResult: null, + ...overrides + } as Parameters[0] & Record +} + +describe('buildMiniflareDevConfig', () => { + test('includes resolved ref service workers in the same Miniflare runtime', () => { + const mfConfig = buildMiniflareDevConfig(buildBaseInput({ + serviceBindingResolution: { + primaryServiceBindings: { + VOICESTORY_API: { + name: 'voicestory-api', + entrypoint: 'VoiceStoryApi' + } + }, + workers: [ + { + name: 'voicestory-api', + modules: true, + script: 'export class VoiceStoryApi { async ping() { return "pong" } }', + compatibilityDate: '2026-04-28' + } + ] + } + })) + + const workerNames = mfConfig.workers.map((worker: { name: string }) => worker.name) + expect(workerNames).toContain('gateway') + expect(workerNames).toContain('voicestory-api') + expect(mfConfig.workers[0].serviceBindings.VOICESTORY_API).toEqual({ + name: 'voicestory-api', + entrypoint: 'VoiceStoryApi' + }) + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/miniflare-log.test.ts b/packages/devflare/tests/unit/dev-server/miniflare-log.test.ts new file mode 100644 index 0000000..7ff1e2b --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/miniflare-log.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, mock, test } from 'bun:test' +import { + createCompatibilityAwareMiniflareLog, + createMiniflareLog, + formatCompatibilityDateFallbackNotice, + resolveMiniflareLogLevel +} from '../../../src/dev-server/miniflare-log' + +const rawCompatibilityWarning = [ + 'The latest compatibility date supported by the installed Cloudflare Workers Runtime is ', + '\u001b[1m"2026-03-17"\u001b[22m', + ',\n', + 'but you\'ve requested ', + '\u001b[1m"2026-03-28"\u001b[22m', + '. Falling back to ', + '\u001b[1m"2026-03-17"\u001b[22m', + '...' +].join('') + +const friendlyCompatibilityNotice = + 'Using latest supported Cloudflare Workers Runtime compatibility date 2026-03-17 (requested 2026-03-28)' + +class FakeMiniflareLog { + readonly warnings: string[] = [] + readonly infos: string[] = [] + + constructor(readonly level?: number) { } + + warn(message: string): void { + this.warnings.push(message) + } + + info(message: string): void { + this.infos.push(message) + } +} + +describe('formatCompatibilityDateFallbackNotice', () => { + test('rewrites Miniflare compatibility fallbacks into a shorter notice', () => { + expect(formatCompatibilityDateFallbackNotice(rawCompatibilityWarning)).toBe(friendlyCompatibilityNotice) + }) + + test('returns null for unrelated warnings', () => { + expect(formatCompatibilityDateFallbackNotice('A different Miniflare warning')).toBeNull() + }) +}) + +describe('createCompatibilityAwareMiniflareLog', () => { + test('routes compatibility fallbacks through the provided logger', () => { + const info = mock((message: string) => message) + const log = createCompatibilityAwareMiniflareLog(FakeMiniflareLog, 4, { info }) + + log.warn(rawCompatibilityWarning) + + expect(info).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledWith(friendlyCompatibilityNotice) + expect(log.warnings).toEqual([]) + expect(log.infos).toEqual([]) + }) + + test('falls back to info logging when no Devflare logger is provided', () => { + const log = createCompatibilityAwareMiniflareLog(FakeMiniflareLog, 4) + + log.warn(rawCompatibilityWarning) + + expect(log.warnings).toEqual([]) + expect(log.infos).toEqual([friendlyCompatibilityNotice]) + }) + + test('passes through unrelated warnings untouched', () => { + const log = createCompatibilityAwareMiniflareLog(FakeMiniflareLog, 4) + + log.warn('A different Miniflare warning') + + expect(log.warnings).toEqual(['A different Miniflare warning']) + expect(log.infos).toEqual([]) + }) +}) + +describe('resolveMiniflareLogLevel', () => { + test('falls back to Miniflare numeric log levels when the enum export is unavailable', () => { + expect(resolveMiniflareLogLevel(undefined, 'WARN')).toBe(2) + expect(resolveMiniflareLogLevel(undefined, 'DEBUG')).toBe(4) + }) +}) + +describe('createMiniflareLog', () => { + test('omits the custom logger when Miniflare does not export the Log constructor', () => { + expect(createMiniflareLog(undefined, undefined, 'WARN')).toBeUndefined() + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/miniflare-worker-config.test.ts b/packages/devflare/tests/unit/dev-server/miniflare-worker-config.test.ts new file mode 100644 index 0000000..0ed0ccc --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/miniflare-worker-config.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test' +import { + makeMiniflareWorker, + type MakeMiniflareWorkerContext +} from '../../../src/dev-server/miniflare-worker-config' + +describe('makeMiniflareWorker', () => { + test('maps Wrangler module rules to Miniflare module rules for file-backed workers', () => { + const context: MakeMiniflareWorkerContext = { + cwd: 'C:/project', + loadedConfig: { + name: 'app-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + baseDir: './worker', + rules: [ + { type: 'Text', globs: ['**/*.txt'], fallthrough: true }, + { type: 'Data', globs: ['**/*.bin'] }, + { type: 'CompiledWasm', globs: ['**/*.wasm'] } + ] + } as any, + bindings: {}, + sendEmailConfig: undefined, + rateLimitsConfig: undefined, + versionMetadataConfig: undefined, + workerLoadersConfig: undefined, + mtlsCertificatesConfig: undefined, + dispatchNamespacesConfig: undefined, + workflowsConfig: undefined, + pipelinesConfig: undefined, + hyperdrivesConfig: { + POSTGRES: 'postgres://user:pass@localhost:5432/app' + }, + imagesConfig: undefined, + mediaConfig: undefined, + aiSearchNamespacesConfig: undefined, + aiSearchInstancesConfig: undefined, + artifactsConfig: undefined, + secretsStoreConfig: undefined, + queueProducers: undefined + } + + const workerConfig = makeMiniflareWorker(context, { + name: 'app-worker', + scriptPath: 'C:/project/.devflare/worker.js' + }) + + expect(workerConfig.modulesRoot).toBe('C:/project/worker') + expect(workerConfig.modulesRules).toEqual([ + { type: 'Text', include: ['**/*.txt'], fallthrough: true }, + { type: 'Data', include: ['**/*.bin'] }, + { type: 'CompiledWasm', include: ['**/*.wasm'] }, + { type: 'ESModule', include: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.mjs'] }, + { type: 'CommonJS', include: ['**/*.js', '**/*.cjs'] }, + { type: 'ESModule', include: ['**/*.jsx'] } + ]) + expect(workerConfig.hyperdrives).toEqual({ + POSTGRES: 'postgres://user:pass@localhost:5432/app' + }) + }) + + test('adds local Secrets Store wrapped bindings to worker configs', () => { + const context: MakeMiniflareWorkerContext = { + cwd: 'C:/project', + loadedConfig: { + name: 'app-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [] + } as any, + bindings: {}, + sendEmailConfig: undefined, + rateLimitsConfig: undefined, + versionMetadataConfig: undefined, + workerLoadersConfig: undefined, + mtlsCertificatesConfig: undefined, + dispatchNamespacesConfig: undefined, + workflowsConfig: undefined, + pipelinesConfig: undefined, + hyperdrivesConfig: undefined, + imagesConfig: undefined, + mediaConfig: undefined, + aiSearchNamespacesConfig: undefined, + aiSearchInstancesConfig: undefined, + artifactsConfig: undefined, + secretsStoreConfig: { + REMOTE_ONLY: { + store_id: 'store-123', + secret_name: 'remote-only' + } + }, + localSecretWrappedBindingConfig: { + localBindingNames: ['API_TOKEN'], + wrappedBindings: { + API_TOKEN: { + scriptName: 'devflare-local-secret-0-api-token', + bindings: { + value: 'local-secret' + } + } + }, + workers: [] + }, + queueProducers: undefined + } + + const workerConfig = makeMiniflareWorker(context, { + name: 'app-worker', + script: 'export default {}' + }) + + expect(workerConfig.secretsStoreSecrets).toEqual({ + REMOTE_ONLY: { + store_id: 'store-123', + secret_name: 'remote-only' + } + }) + expect(workerConfig.wrappedBindings).toEqual({ + API_TOKEN: { + scriptName: 'devflare-local-secret-0-api-token', + bindings: { + value: 'local-secret' + } + } + }) + }) +}) diff --git a/packages/devflare/tests/unit/dev-server/runtime-stdio.test.ts b/packages/devflare/tests/unit/dev-server/runtime-stdio.test.ts new file mode 100644 index 0000000..35e38e6 --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/runtime-stdio.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, mock, test } from 'bun:test' +import { PassThrough } from 'node:stream' +import { createRuntimeStdioForwarder } from '../../../src/dev-server/runtime-stdio' + +async function waitForForwarding(): Promise { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 10)) +} + +describe('createRuntimeStdioForwarder', () => { + test('forwards stdout lines to logger.log when available', async () => { + const stdout = new PassThrough() + const stderr = new PassThrough() + const log = mock((message: string) => message) + const error = mock((message: string) => message) + + const forward = createRuntimeStdioForwarder({ log, error }) + forward(stdout, stderr) + + stdout.write('fetch log\nqueue log\n') + stdout.end() + stderr.end() + + await waitForForwarding() + + expect(log.mock.calls.map(([message]) => message)).toEqual([ + 'fetch log', + 'queue log' + ]) + expect(error).not.toHaveBeenCalled() + }) + + test('falls back to logger.info for stdout when logger.log is unavailable', async () => { + const stdout = new PassThrough() + const stderr = new PassThrough() + const info = mock((message: string) => message) + + const forward = createRuntimeStdioForwarder({ info }) + forward(stdout, stderr) + + stdout.write('worker-only log\n') + stdout.end() + stderr.end() + + await waitForForwarding() + + expect(info).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledWith('worker-only log') + }) + + test('forwards stderr lines to logger.error', async () => { + const stdout = new PassThrough() + const stderr = new PassThrough() + const error = mock((message: string) => message) + + const forward = createRuntimeStdioForwarder({ error }) + forward(stdout, stderr) + + stderr.write('runtime failure\n') + stdout.end() + stderr.end() + + await waitForForwarding() + + expect(error).toHaveBeenCalledTimes(1) + expect(error).toHaveBeenCalledWith('runtime failure') + }) +}) \ No newline at end of file diff --git a/packages/devflare/tests/unit/dev-server/vite-utils.test.ts b/packages/devflare/tests/unit/dev-server/vite-utils.test.ts new file mode 100644 index 0000000..19baeca --- /dev/null +++ b/packages/devflare/tests/unit/dev-server/vite-utils.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from 'bun:test' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { + detectViteProject, + extractViteReadyUrl, + stopSpawnedProcessTree, + waitForViteReady, + type SpawnedLikeProcess, + type ViteProjectFileSystem +} from '../../../src/dev-server/vite-utils' + +function createMockFs(files: Record): ViteProjectFileSystem { + return { + async access(path: string) { + if (!(path in files)) { + throw new Error(`ENOENT: ${path}`) + } + }, + async readFile(path: string) { + if (!(path in files)) { + throw new Error(`ENOENT: ${path}`) + } + return files[path] + } + } +} + +class FakeSpawnedProcess extends EventEmitter implements SpawnedLikeProcess { + pid?: number + stdout: PassThrough | null + stderr: PassThrough | null + killed = false + readonly killSignals: string[] = [] + + constructor(pid = 1234) { + super() + this.pid = pid + this.stdout = new PassThrough() + this.stderr = new PassThrough() + } + + kill(signal?: NodeJS.Signals): boolean { + this.killed = true + this.killSignals.push(signal ?? 'SIGTERM') + return true + } + + override on(event: 'exit' | 'error', handler: (...args: any[]) => void): this { + return super.on(event, handler) + } +} + +describe('detectViteProject', () => { + test('keeps worker-only packages in worker-only mode even when a sibling package uses Vite', async () => { + const fs = createMockFs({ + '/repo/projects/worker/package.json': JSON.stringify({ + name: 'worker-only', + devDependencies: { + devflare: '^1.0.0' + } + }), + '/repo/projects/extension/package.json': JSON.stringify({ + name: 'frontend', + devDependencies: { + vite: '^6.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + }), + '/repo/projects/extension/vite.config.ts': 'export default {}' + }) + + const result = await detectViteProject('/repo/projects/worker', fs) + + expect(result.shouldStartVite).toBe(false) + expect(result.wantsViteIntegration).toBe(false) + }) + + test('starts Vite only when the current package has a local vite.config file', async () => { + const fs = createMockFs({ + '/repo/worker/package.json': JSON.stringify({ + name: 'worker', + devDependencies: { + vite: '^6.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + }), + '/repo/worker/vite.config.ts': 'export default {}' + }) + + const result = await detectViteProject('/repo/worker', fs) + + expect(result.shouldStartVite).toBe(true) + expect(result.wantsViteIntegration).toBe(true) + expect(result.viteConfigPath).toBe('/repo/worker/vite.config.ts') + }) + + test('recognizes cts and cjs vite config filenames in the current package', async () => { + for (const configName of ['vite.config.cts', 'vite.config.cjs']) { + const fs = createMockFs({ + '/repo/worker/package.json': JSON.stringify({ + name: 'worker', + devDependencies: { + vite: '^6.0.0' + } + }), + [`/repo/worker/${configName}`]: 'export default {}' + }) + + const result = await detectViteProject('/repo/worker', fs) + + expect(result.shouldStartVite).toBe(true) + expect(result.viteConfigPath).toBe(`/repo/worker/${configName}`) + } + }) + + test('detects Vite intent without starting Vite when dependencies exist but config is missing', async () => { + const fs = createMockFs({ + '/repo/worker/package.json': JSON.stringify({ + name: 'worker', + devDependencies: { + vite: '^6.0.0', + '@cloudflare/vite-plugin': '^1.0.0' + } + }) + }) + + const result = await detectViteProject('/repo/worker', fs) + + expect(result.shouldStartVite).toBe(false) + expect(result.wantsViteIntegration).toBe(true) + expect(result.hasLocalViteDependency).toBe(true) + expect(result.hasLocalCloudflareVitePluginDependency).toBe(true) + }) +}) + +describe('extractViteReadyUrl', () => { + test('returns the actual local Vite URL after port retries', () => { + const output = [ + 'Port 5173 is in use, trying another one...', + 'Port 5174 is in use, trying another one...', + '\u001b[32m โžœ \u001b[39m\u001b[1mLocal\u001b[22m: \u001b[36mhttp://localhost:5180/\u001b[39m' + ].join('\n') + + expect(extractViteReadyUrl(output)).toBe('http://localhost:5180/') + }) +}) + +describe('waitForViteReady', () => { + test('waits for Vite to report the final bound port', async () => { + const process = new FakeSpawnedProcess() + const forwardedStdout: string[] = [] + + const readyPromise = waitForViteReady(process, { + timeoutMs: 100, + onStdout(chunk) { + forwardedStdout.push(typeof chunk === 'string' ? chunk : chunk.toString('utf-8')) + } + }) + + process.stdout?.write('Port 5173 is in use, trying another one...\n') + process.stdout?.write(' โžœ Local: http://localhost:5180/\n') + + expect(await readyPromise).toBe('http://localhost:5180/') + expect(forwardedStdout.join('')).toContain('http://localhost:5180/') + }) +}) + +describe('stopSpawnedProcessTree', () => { + test('uses taskkill to stop the full process tree on Windows', async () => { + const process = new FakeSpawnedProcess(4242) + const commands: Array<{ command: string; args: string[] }> = [] + + const stopPromise = stopSpawnedProcessTree(process, { + platform: 'win32', + timeoutMs: 25, + runCommand: async (command, args) => { + commands.push({ command, args }) + queueMicrotask(() => { + process.killed = true + process.emit('exit', 0, null) + }) + } + }) + + await stopPromise + + expect(commands).toEqual([ + { + command: 'taskkill', + args: ['/pid', '4242', '/t', '/f'] + } + ]) + }) +}) diff --git a/packages/devflare/tests/unit/docs/cloudflare-reference-headers.test.ts b/packages/devflare/tests/unit/docs/cloudflare-reference-headers.test.ts new file mode 100644 index 0000000..fd197c8 --- /dev/null +++ b/packages/devflare/tests/unit/docs/cloudflare-reference-headers.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { docs } from '../../../../../apps/documentation/src/lib/docs/content' +import { bindingDocCategories } from '../../../../../apps/documentation/src/lib/docs/content/bindings' + +interface HeaderCloudflareDocs { + label?: string + title?: string + href?: string + summary?: string +} + +interface HeaderSupportBadge { + label?: string + tooltip?: string +} + +function workspacePath(...segments: string[]): string { + return join(import.meta.dir, '..', '..', '..', '..', '..', ...segments) +} + +function bindingSlugs(): string[] { + return bindingDocCategories.flatMap((category) => category.slugs) +} + +function headerCloudflareDocs(slug: string): HeaderCloudflareDocs | undefined { + const doc = docs.find((candidate) => candidate.slug === slug) + return (doc as { headerCloudflareDocs?: HeaderCloudflareDocs } | undefined)?.headerCloudflareDocs +} + +function headerSupportBadge(slug: string): HeaderSupportBadge | undefined { + const doc = docs.find((candidate) => candidate.slug === slug) + return (doc as { headerSupport?: HeaderSupportBadge } | undefined)?.headerSupport +} + +function headerReferenceFailures(slug: string): string[] { + const reference = headerCloudflareDocs(slug) + const support = headerSupportBadge(slug) + const problems: string[] = [] + + if (!reference) { + return [`${slug}: missing headerCloudflareDocs`] + } + + if (reference.label !== 'Cloudflare Documentation') { + problems.push(`${slug}: button label is not stable`) + } + + if (!reference.href?.startsWith('https://developers.cloudflare.com/')) { + problems.push(`${slug}: Cloudflare docs href is not an official docs URL`) + } + + if (!reference.title?.startsWith('Cloudflare ')) { + problems.push(`${slug}: title does not name the Cloudflare docs target`) + } + + if (!reference.summary || reference.summary.length < 40 || reference.summary.length > 180) { + problems.push(`${slug}: intro summary is missing or not concise`) + } + + if (!support?.label || !['Full', 'Remote', 'Limited'].includes(support.label)) { + problems.push(`${slug}: missing header support badge`) + } + + if (!support?.tooltip || support.tooltip.length < 40) { + problems.push(`${slug}: support badge tooltip is missing or too terse`) + } + + return problems +} + +describe('Cloudflare reference headers', () => { + test('binding pages expose a short product intro and Cloudflare docs link', () => { + const failures = bindingSlugs().flatMap(headerReferenceFailures) + + expect(failures).toEqual([]) + }) + + test('article header renders the Cloudflare docs action as a new-tab button', () => { + const source = readFileSync( + workspacePath( + 'apps', + 'documentation', + 'src', + 'lib', + 'components', + 'article', + 'Article.svelte' + ), + 'utf8' + ) + + expect(source).toContain('doc.headerCloudflareDocs') + expect(source).toContain('doc.headerSupport') + expect(source).toContain('use:tooltip={doc.headerSupport.tooltip}') + expect(source).toContain('Cloudflare Documentation') + expect(source).toContain('target="_blank"') + expect(source).toContain('fluent--open-16-regular') + }) + + test('article header places product intro after the h1', () => { + const source = readFileSync( + workspacePath( + 'apps', + 'documentation', + 'src', + 'lib', + 'components', + 'article', + 'Article.svelte' + ), + 'utf8' + ) + const headingIndex = source.indexOf(' { + return doc.sections.flatMap((section) => { + return (section.snippets ?? []).flatMap((snippet) => { + return snippetFiles(snippet).flatMap((file) => { + const failures: string[] = [] + for (const objectMatch of file.code.matchAll(objectLiteralPattern)) { + const pattern = objectMatch[0].match(routePattern)?.[1] + if (pattern && /[/*]/.test(pattern)) { + failures.push( + `${doc.slug}/${section.id}/${snippet.title}/${file.path ?? snippet.filename ?? 'inline'}: ${pattern}` + ) + } + } + return failures + }) + }) + }) + }) +} diff --git a/packages/devflare/tests/unit/docs/documentation-integrity.test.ts b/packages/devflare/tests/unit/docs/documentation-integrity.test.ts new file mode 100644 index 0000000..357739a --- /dev/null +++ b/packages/devflare/tests/unit/docs/documentation-integrity.test.ts @@ -0,0 +1,993 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import ts from 'typescript' +import { docs } from '../../../../../apps/documentation/src/lib/docs/content' +import { bindingDocCategories } from '../../../../../apps/documentation/src/lib/docs/content/bindings' +import { buildLLMDocument } from '../../../../../apps/documentation/src/lib/docs/llm' +import type { DocCodeSnippet } from '../../../../../apps/documentation/src/lib/docs/types' +import { COMMANDS } from '../../../src/cli/help' +import { rootConfigShape } from '../../../src/config/schema' +import { bindingsSchema } from '../../../src/config/schema-bindings' +import { snippetsWithInvalidCustomDomainRoutes } from './custom-domain-route-snippets' + +interface CodeFence { + index: number + language: string + code: string + startLine: number +} + +function workspacePath(...segments: string[]): string { + return join(import.meta.dir, '..', '..', '..', '..', '..', ...segments) +} + +async function readPackageReadme(): Promise { + const readme = await readFile(join(import.meta.dir, '..', '..', '..', 'README.md'), 'utf8') + return readme.replace(/\r\n/g, '\n') +} + +async function readCasesReadme(): Promise { + const readme = await readFile(workspacePath('cases', 'README.md'), 'utf8') + return readme.replace(/\r\n/g, '\n') +} + +async function readDocsAppReadme(): Promise { + const readme = await readFile(workspacePath('apps', 'documentation', 'README.md'), 'utf8') + return readme.replace(/\r\n/g, '\n') +} + +async function readPackageLlm(): Promise { + const readme = await readFile(workspacePath('packages', 'devflare', 'LLM.md'), 'utf8') + return readme.replace(/\r\n/g, '\n') +} + +function extractCodeFences(markdown: string): CodeFence[] { + const fences: CodeFence[] = [] + const pattern = /```([a-zA-Z0-9_-]+)?\r?\n([\s\S]*?)```/g + let match: RegExpExecArray | null + let index = 0 + + while (true) { + match = pattern.exec(markdown) + if (match === null) { + break + } + + fences.push({ + index, + language: match[1] ?? '', + code: match[2], + startLine: markdown.slice(0, match.index).split(/\r?\n/).length + }) + index++ + } + + return fences +} + +function parseDiagnosticsFor(fence: CodeFence): string[] { + if (!['ts', 'tsx', 'js'].includes(fence.language)) { + return [] + } + + const sourceFile = ts.createSourceFile( + `readme-${fence.index}.${fence.language}`, + fence.code, + ts.ScriptTarget.Latest, + true, + fence.language === 'tsx' + ? ts.ScriptKind.TSX + : fence.language === 'js' + ? ts.ScriptKind.JS + : ts.ScriptKind.TS + ) + + return sourceFile.parseDiagnostics.map((diagnostic) => { + return `line ${fence.startLine}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}` + }) +} + +function parseSnippetDiagnostics(slug: string, snippet: DocCodeSnippet): string[] { + const files = + snippet.files ?? + (snippet.code + ? [ + { + path: snippet.filename, + language: snippet.language, + code: snippet.code + } + ] + : []) + + return files.flatMap((file) => { + const language = file.language ?? file.path?.split('.').at(-1) ?? '' + if (!['ts', 'tsx', 'js', 'jsx'].includes(language)) { + return [] + } + + return parseDiagnosticsFor({ + index: 0, + language: language === 'jsx' ? 'tsx' : language, + code: file.code, + startLine: 1 + }).map((diagnostic) => `${slug}/${snippet.title}/${file.path ?? 'inline'}: ${diagnostic}`) + }) +} + +function getFenceContaining(fences: CodeFence[], text: string): CodeFence { + const fence = fences.find((candidate) => candidate.code.includes(text)) + expect(fence, `Expected README to include a code fence containing ${text}`).toBeDefined() + return fence as CodeFence +} + +async function runCommand( + command: string[], + cwd: string +): Promise<{ + exitCode: number + output: string +}> { + const process = Bun.spawn({ + cmd: command, + cwd, + env: { + ...Bun.env, + NO_COLOR: '1' + }, + stderr: 'pipe', + stdout: 'pipe' + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited + ]) + + return { + exitCode, + output: `${stdout}${stderr}` + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function documentedEntrypointNames(readme: string, importPath: string): string[] { + const row = readme.match( + new RegExp(`^\\| \`${escapeRegExp(importPath)}\` \\| (?.+?) \\|$`, 'm') + ) + expect(row?.groups?.description).toBeDefined() + + return [...(row?.groups?.description.matchAll(/`([^`]+)`/g) ?? [])] + .map((match) => match[1].replace(/\(\)$/g, '')) + .flatMap((name) => name.split('/')) + .map((name) => name.trim()) + .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name)) +} + +function localSourceExists(source: string): boolean { + if (/^https?:\/\//.test(source)) { + return true + } + + if (source.includes('*')) { + const prefix = source.slice(0, source.indexOf('*')).replace(/\/$/, '') + return getWorkspaceFiles().some( + (path) => + path.startsWith(prefix) || path.includes(`/${prefix}/`) || path.endsWith(`/${prefix}`) + ) + } + + if (existsSync(workspacePath(source))) { + return true + } + + return getWorkspaceFiles().some((path) => path === source || path.endsWith(`/${source}`)) +} + +let workspaceFiles: string[] | undefined + +function getWorkspaceFiles(): string[] { + workspaceFiles ??= listFiles(workspacePath()).map((path) => path.replace(/\\/g, '/')) + return workspaceFiles +} + +function listFiles(root: string, relative = ''): string[] { + const ignoredDirectories = new Set([ + '.git', + '.svelte-kit', + '.turbo', + '.wrangler', + 'dist', + 'node_modules' + ]) + + return readdirSync(join(root, relative)).flatMap((entry) => { + if (ignoredDirectories.has(entry)) { + return [] + } + + const entryRelativePath = relative ? join(relative, entry) : entry + const entryAbsolutePath = join(root, entryRelativePath) + const stats = statSync(entryAbsolutePath) + + if (stats.isDirectory()) { + return listFiles(root, entryRelativePath) + } + + return [entryRelativePath] + }) +} + +function bindingSchemaKeys(): string[] { + const schema = bindingsSchema as unknown as { + _def?: { innerType?: { shape: Record } } + shape?: Record + } + + return Object.keys(schema._def?.innerType?.shape ?? schema.shape ?? {}) +} + +function documentedTopLevelConfigKeys(readme: string): string[] { + const section = readme.match( + /The most important top-level keys are:\n\n(?(?:- `[^`]+`\n)+)/ + ) + expect(section?.groups?.list).toBeDefined() + + return [...(section?.groups?.list.matchAll(/- `([^`]+)`/g) ?? [])] + .map((match) => (match[1] === 'wrangler.passthrough' ? 'wrangler' : match[1])) + .sort() +} + +function documentedCliCommands(readme: string): string[] { + const commandRows = [...readme.matchAll(/^\| `devflare ([a-z-]+)` \| .+ \|$/gm)] + return commandRows.map((match) => match[1]).sort() +} + +function standaloneCaseDirectories(): string[] { + return readdirSync(workspacePath('cases'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^case\d+$/.test(entry.name)) + .map((entry) => entry.name) + .sort() +} + +function documentedCasesFromQuickReference(readme: string): string[] { + return [...readme.matchAll(/^\| (?\d+) \| /gm)] + .map((match) => `case${match.groups?.number}`) + .sort() +} + +function documentedCasesFromDetailHeadings(readme: string): string[] { + return [...readme.matchAll(/^### Case (?\d+): /gm)] + .map((match) => `case${match.groups?.number}`) + .sort() +} + +function docsText(): string { + return JSON.stringify(docs) +} + +function docText(slug: string): string { + const doc = docs.find((candidate) => candidate.slug === slug) + expect(doc, `Expected docs to include ${slug}`).toBeDefined() + return JSON.stringify(doc) +} + +function bindingSlugsAt(index: number): string[] { + return bindingDocCategories.map((category) => category.slugs[index]) +} + +function bindingOverviewLinks(): string[] { + return bindingSlugsAt(0).map((slug) => `/docs/${slug}`) +} + +const bindingSupportLevels = new Set(['Full', 'Remote', 'Limited']) + +function isInlineCodeFactValue(value: string): boolean { + return /^`[^`]+`$/.test(value) +} + +function bindingOverviewSupportFailures(slug: string): string[] { + const doc = docs.find((candidate) => candidate.slug === slug) + const supportSection = doc?.sections.find((section) => section.id === 'local-and-remote-support') + const configKey = doc?.facts.find((fact) => fact.label === 'Config key')?.value ?? '' + const authoringShape = doc?.facts.find((fact) => fact.label === 'Authoring shape')?.value ?? '' + const failures: string[] = [] + + if (!supportSection) { + failures.push(`${slug}: missing Local and Remote Support section`) + } + + if (supportSection?.title !== 'Local and Remote Support') { + failures.push(`${slug}: support section title is not stable`) + } + + const supportLabel = supportSection?.label + if (!supportLabel || !bindingSupportLevels.has(supportLabel)) { + failures.push(`${slug}: missing supported Full/Remote/Limited section label`) + } + + if (!supportSection?.labelTooltip) { + failures.push(`${slug}: support section label lacks hover explanation`) + } + if (supportSection?.paragraphs?.some((paragraph) => paragraph.includes('Support level:'))) { + failures.push(`${slug}: support section still writes the support level as body text`) + } + + const redundantTitles = [`${supportLabel} support`, 'What works without Cloudflare', 'When to connect to Cloudflare'] + const redundantCardTitle = supportSection?.cards + ?.map((card) => card.title) + .find((title) => redundantTitles.includes(title)) + if (redundantCardTitle) { + failures.push(`${slug}: support section still includes redundant "${redundantCardTitle}" card`) + } + + if (JSON.stringify(supportSection ?? {}).includes('Partial')) { + failures.push(`${slug}: support section still says Partial`) + } + + if (!isInlineCodeFactValue(configKey)) { + failures.push(`${slug}: Config key is not inline code`) + } + + if (!isInlineCodeFactValue(authoringShape)) { + failures.push(`${slug}: Authoring shape is not inline code`) + } + + return failures +} + +function bindingDocsMissingSection(index: number, sectionId: string): string[] { + return bindingSlugsAt(index).filter((slug) => { + const doc = docs.find((candidate) => candidate.slug === slug) + return !doc?.sections.some((section) => section.id === sectionId) + }) +} + +function bindingDocsMatching(index: number, pattern: RegExp): string[] { + return bindingSlugsAt(index).filter((slug) => pattern.test(docText(slug))) +} + +function bindingSectionTexts( + index: number, + sectionId: string +): Array<{ slug: string; text: string }> { + return bindingSlugsAt(index).map((slug) => { + const doc = docs.find((candidate) => candidate.slug === slug) + const section = doc?.sections.find((candidate) => candidate.id === sectionId) + return { slug, text: JSON.stringify(section) } + }) +} + +function bindingReaderText(index: number): Array<{ slug: string; text: string }> { + return bindingSlugsAt(index).map((slug) => { + const doc = docs.find((candidate) => candidate.slug === slug) + expect(doc, `Expected docs to include ${slug}`).toBeDefined() + + return { + slug, + text: JSON.stringify({ + title: doc?.title, + summary: doc?.summary, + description: doc?.description, + highlights: doc?.highlights, + facts: doc?.facts, + sections: doc?.sections.map((section) => ({ + id: section.id, + title: section.title, + description: section.description, + paragraphs: section.paragraphs, + bullets: section.bullets, + steps: section.steps, + cards: section.cards?.map((card) => ({ + label: card.label, + meta: card.meta, + title: card.title, + body: card.body + })), + callouts: section.callouts?.map((callout) => ({ + title: callout.title, + body: callout.body + })) + })) + }) + } + }) +} + +function snippetsWithBareDevflareEnvImports(): string[] { + return docs.flatMap((doc) => { + return doc.sections.flatMap((section) => { + return (section.snippets ?? []).flatMap((snippet) => { + const files = + snippet.files ?? + (snippet.code + ? [ + { + path: snippet.filename, + code: snippet.code + } + ] + : []) + + return files + .filter((file) => file.code.includes("import { env } from 'devflare'")) + .map((file) => `${doc.slug}/${section.id}/${snippet.title}/${file.path ?? 'inline'}`) + }) + }) + }) +} + +function isCommandLanguage(language: string | undefined): boolean { + return ['bash', 'console', 'powershell', 'ps1', 'shell', 'sh', 'zsh'].includes( + (language ?? '').toLowerCase() + ) +} + +function isCommandSnippet(snippet: DocCodeSnippet): boolean { + const files = snippetFiles(snippet) + + if (files.length > 0) { + return files.every((file) => isCommandLanguage(file.language ?? snippet.language)) + } + + return isCommandLanguage(snippet.language) +} + +function hasInferredSnippetPath(snippet: DocCodeSnippet): boolean { + const code = snippet.code?.trim() + if (!code || isCommandSnippet(snippet)) { + return true + } + + return [ + /\bdefineConfig\s*\(/, + /from ['"]devflare\/config['"]/, + /from ['"]bun:test['"]/, + /\bexport\s+class\s+([A-Z][A-Za-z0-9_]*)\s+extends\s+DurableObject(?:<[^>]+>)?\b/, + /\bextends\s+WorkerEntrypoint\b/, + /\bexport\s+(?:async\s+)?function\s+fetch\b/, + /\bexport\s+const\s+handle\b/, + /\bexport\s+(?:async\s+)?function\s+queue\b/, + /\bexport\s+(?:async\s+)?function\s+scheduled\b/, + /\bexport\s+(?:async\s+)?function\s+email\b/, + /\bForwardableEmailMessage\b/, + /\bexport\s+(?:async\s+)?function\s+tail\b/, + /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/ + ].some((pattern) => pattern.test(code)) +} + +function snippetsWithoutProvenance(): string[] { + return docs.flatMap((doc) => { + return doc.sections.flatMap((section) => { + return (section.snippets ?? []).flatMap((snippet) => { + if (snippet.files?.length || snippet.filename || !snippet.code) { + return [] + } + + if (/inline|fragment/i.test(`${snippet.title} ${snippet.description ?? ''}`)) { + return [] + } + + return hasInferredSnippetPath(snippet) ? [] : [`${doc.slug}/${section.id}/${snippet.title}`] + }) + }) + }) +} + +function snippetFiles(snippet: DocCodeSnippet): Array<{ + path?: string + language?: string + code: string +}> { + return ( + snippet.files ?? + (snippet.code + ? [ + { + path: snippet.filename, + language: snippet.language, + code: snippet.code + } + ] + : []) + ) +} + +function isCopyPastableExample(snippet: DocCodeSnippet): boolean { + const files = snippetFiles(snippet) + if (files.length === 0 || files.some((file) => file.code.trim().length === 0)) { + return false + } + + if (isCommandSnippet(snippet)) { + return files.some((file) => + /\b(?:bun|npm|pnpm|devflare|wrangler|curl|docker|podman)\b/.test(file.code) + ) + } + + const hasConcreteLocation = + Boolean(snippet.filename) || + files.some((file) => Boolean(file.path)) || + hasInferredSnippetPath(snippet) + const exampleCode = files.map((file) => file.code).join('\n') + + return ( + hasConcreteLocation && + /(?:\bdefineConfig\b|from ['"]devflare\/|import\s+.+\s+from\s+['"]|export\s+(?:async\s+)?function|export\s+class|const\s+\w+\s*=|async\s*\(|await\s+|fetch\s*\(|env\.|uses:|run:|steps:|jobs:)/.test( + exampleCode + ) + ) +} + +function nonEmptyLineCount(files: Array<{ code: string }>): number { + return files.reduce((sum, file) => { + return sum + file.code.trim().split(/\r?\n/).filter(Boolean).length + }, 0) +} + +function isProjectShapedExample(snippet: DocCodeSnippet): boolean { + const files = snippetFiles(snippet) + + if ( + files.length === 0 || + isCommandSnippet(snippet) || + nonEmptyLineCount(files) < 6 || + !( + Boolean(snippet.filename) || + Boolean(snippet.activeFile) || + files.some((file) => Boolean(file.path)) || + hasInferredSnippetPath(snippet) + ) + ) { + return false + } + + const exampleCode = files.map((file) => file.code).join('\n') + + return /(?:\bdefineConfig\b|from ['"]devflare\/|import\s+.+\s+from\s+['"]|export\s+(?:async\s+)?function|export\s+class|env\.|uses:|run:|jobs:|on:|scripts)/.test( + exampleCode + ) +} + +function pagesWithoutCopyPastableExamples(): string[] { + return docs + .filter((doc) => { + return !doc.sections.some((section) => { + return (section.snippets ?? []).some((snippet) => isCopyPastableExample(snippet)) + }) + }) + .map((doc) => doc.slug) +} + +function pagesWithoutProjectShapedExamples(): string[] { + return docs + .filter((doc) => { + return !doc.sections.some((section) => { + return (section.snippets ?? []).some((snippet) => isProjectShapedExample(snippet)) + }) + }) + .map((doc) => doc.slug) +} + +function docsWithDuplicateSourcePages(): string[] { + return docs.flatMap((doc) => { + const seen = new Set() + + return doc.sourcePages.flatMap((source) => { + if (seen.has(source)) { + return [`${doc.slug}: ${source}`] + } + + seen.add(source) + return [] + }) + }) +} + +describe('documentation integrity', () => { + test('README TypeScript and JavaScript code fences parse cleanly', async () => { + const readme = await readPackageReadme() + const diagnostics = extractCodeFences(readme).flatMap((fence) => parseDiagnosticsFor(fence)) + + expect(diagnostics).toEqual([]) + }) + + test('docs app TypeScript and JavaScript snippets parse cleanly', () => { + const diagnostics = docs.flatMap((doc) => { + return doc.sections.flatMap((section) => { + return (section.snippets ?? []).flatMap((snippet) => + parseSnippetDiagnostics(doc.slug, snippet) + ) + }) + }) + + expect(diagnostics).toEqual([]) + }) + + test('docs app snippets use explicit env imports for worker and test code', () => { + expect(snippetsWithBareDevflareEnvImports()).toEqual([]) + }) + + test('docs app snippets do not show wildcard or path patterns for custom domains', () => { + expect(snippetsWithInvalidCustomDomainRoutes()).toEqual([]) + }) + + test('docs app snippets have a file path, command language, inferred path, or inline-fragment label', () => { + expect(snippetsWithoutProvenance()).toEqual([]) + }) + + test('docs app multi-file snippets name every file', () => { + const missingPaths = docs.flatMap((doc) => { + return doc.sections.flatMap((section) => { + return (section.snippets ?? []).flatMap((snippet) => { + return (snippet.files ?? []) + .filter((file) => !file.path) + .map((file) => `${doc.slug}/${section.id}/${snippet.title}: ${file.code.slice(0, 40)}`) + }) + }) + }) + + expect(missingPaths).toEqual([]) + }) + + test('docs app pages include copy-pastable real-world examples', () => { + expect(pagesWithoutCopyPastableExamples()).toEqual([]) + }) + + test('docs app pages include at least one project-shaped non-command example', () => { + expect(pagesWithoutProjectShapedExamples()).toEqual([]) + }) + + test('README quickstart install and first test are internally consistent', async () => { + const readme = await readPackageReadme() + const fences = extractCodeFences(readme) + const fetchSnippet = getFenceContaining(fences, '// src/fetch.ts') + const testSnippet = getFenceContaining(fences, '// tests/worker.test.ts') + + expect(readme).toContain('bun add -d devflare\n') + expect(readme).toContain('For a worker-only project, install only Devflare') + expect(readme).toContain('For Vite-backed apps, add Vite') + expect(readme).toContain('Add the Cloudflare Vite plugin only when') + expect(fetchSnippet.code).toContain("return new Response('Hello from Devflare')") + expect(testSnippet.code).toContain("expect(await response.text()).toBe('Hello from Devflare')") + }) + + test('README quickstart snippets run as a temporary worker project', async () => { + const readme = await readPackageReadme() + const fences = extractCodeFences(readme) + const configSnippet = getFenceContaining(fences, '// devflare.config.ts') + const fetchSnippet = getFenceContaining(fences, '// src/fetch.ts') + const testSnippet = getFenceContaining(fences, '// tests/worker.test.ts') + const projectDir = workspacePath('.local', 'tmp', `docs-quickstart-${Date.now()}`) + + await mkdir(join(projectDir, 'src'), { recursive: true }) + await mkdir(join(projectDir, 'tests'), { recursive: true }) + + try { + await writeFile( + join(projectDir, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { devflare: 'workspace:*' } }, null, 2) + ) + await writeFile(join(projectDir, 'devflare.config.ts'), configSnippet.code) + await writeFile(join(projectDir, 'src', 'fetch.ts'), fetchSnippet.code) + await writeFile(join(projectDir, 'tests', 'worker.test.ts'), testSnippet.code) + + const result = await runCommand(['bun', 'test', 'tests/worker.test.ts'], projectDir) + + expect(result.output).toContain('1 pass') + expect(result.exitCode).toBe(0) + } finally { + await rm(projectDir, { force: true, recursive: true }) + } + }) + + test('README package entrypoint rows only name actual exports', async () => { + const readme = await readPackageReadme() + const entrypoints = [ + { importPath: 'devflare', mod: await import('../../../src/index') }, + { importPath: 'devflare/config', mod: await import('../../../src/config') }, + { importPath: 'devflare/runtime', mod: await import('../../../src/runtime') }, + { importPath: 'devflare/test', mod: await import('../../../src/test') }, + { importPath: 'devflare/vite', mod: await import('../../../src/vite') }, + { importPath: 'devflare/sveltekit', mod: await import('../../../src/sveltekit') }, + { importPath: 'devflare/cloudflare', mod: await import('../../../src/cloudflare') }, + { importPath: 'devflare/decorators', mod: await import('../../../src/decorators') } + ] + const missingExports = entrypoints.flatMap(({ importPath, mod }) => { + const actualExports = new Set(Object.keys(mod)) + return documentedEntrypointNames(readme, importPath) + .filter((name) => !actualExports.has(name)) + .map((name) => `${importPath}: ${name}`) + }) + + expect(missingExports).toEqual([]) + }) + + test('docs app source metadata points at existing local files or external URLs', () => { + const missingSources = docs.flatMap((doc) => { + return doc.sourcePages + .filter((source) => !localSourceExists(source)) + .map((source) => `${doc.slug}: ${source}`) + }) + + expect(missingSources).toEqual([]) + }) + + test('docs app source metadata does not list duplicate sources per page', () => { + expect(docsWithDuplicateSourcePages()).toEqual([]) + }) + + test('docs app has binding categories for every native binding family', () => { + const documentedSlugs = new Set(bindingDocCategories.map((category) => category.slugs[0])) + const expectedSlugs = [ + 'bindings/kv', + 'bindings/d1', + 'bindings/r2', + 'bindings/durable-objects', + 'bindings/queues', + 'bindings/services', + 'bindings/rate-limiting', + 'bindings/version-metadata', + 'bindings/worker-loaders', + 'bindings/secrets-store', + 'bindings/ai', + 'bindings/ai-search', + 'bindings/vectorize', + 'bindings/hyperdrive', + 'bindings/browser-rendering', + 'bindings/analytics-engine', + 'bindings/send-email', + 'bindings/mtls-certificates', + 'bindings/dispatch-namespaces', + 'bindings/workflows', + 'bindings/pipelines', + 'bindings/images', + 'bindings/media-transformations', + 'bindings/artifacts' + ] + + const schemaKeyToSlug: Record = { + kv: 'bindings/kv', + d1: 'bindings/d1', + r2: 'bindings/r2', + durableObjects: 'bindings/durable-objects', + queues: 'bindings/queues', + rateLimits: 'bindings/rate-limiting', + versionMetadata: 'bindings/version-metadata', + workerLoaders: 'bindings/worker-loaders', + secretsStore: 'bindings/secrets-store', + services: 'bindings/services', + ai: 'bindings/ai', + aiSearchNamespaces: 'bindings/ai-search', + aiSearch: 'bindings/ai-search', + vectorize: 'bindings/vectorize', + hyperdrive: 'bindings/hyperdrive', + browser: 'bindings/browser-rendering', + analyticsEngine: 'bindings/analytics-engine', + sendEmail: 'bindings/send-email', + mtlsCertificates: 'bindings/mtls-certificates', + dispatchNamespaces: 'bindings/dispatch-namespaces', + workflows: 'bindings/workflows', + pipelines: 'bindings/pipelines', + images: 'bindings/images', + media: 'bindings/media-transformations', + artifacts: 'bindings/artifacts' + } + + expect(Object.keys(schemaKeyToSlug).sort()).toEqual(bindingSchemaKeys().sort()) + + expect(expectedSlugs.filter((slug) => !documentedSlugs.has(slug))).toEqual([]) + }) + + test('what-devflare-is links every binding page with a support level', () => { + const expectedSupportByLink: Record = { + '/docs/bindings/kv': 'Full', + '/docs/bindings/d1': 'Full', + '/docs/bindings/r2': 'Full', + '/docs/bindings/durable-objects': 'Full', + '/docs/bindings/queues': 'Full', + '/docs/bindings/services': 'Full', + '/docs/bindings/ai': 'Remote', + '/docs/bindings/vectorize': 'Remote', + '/docs/bindings/hyperdrive': 'Full', + '/docs/bindings/browser-rendering': 'Full', + '/docs/bindings/analytics-engine': 'Remote', + '/docs/bindings/send-email': 'Full', + '/docs/bindings/rate-limiting': 'Full', + '/docs/bindings/version-metadata': 'Full', + '/docs/bindings/worker-loaders': 'Full', + '/docs/bindings/secrets-store': 'Full', + '/docs/bindings/ai-search': 'Remote', + '/docs/bindings/mtls-certificates': 'Remote', + '/docs/bindings/dispatch-namespaces': 'Remote', + '/docs/bindings/workflows': 'Full', + '/docs/bindings/pipelines': 'Remote', + '/docs/bindings/images': 'Full', + '/docs/bindings/media-transformations': 'Full', + '/docs/bindings/artifacts': 'Remote', + '/docs/bindings/containers': 'Full' + } + const page = docs.find((doc) => doc.slug === 'what-devflare-is') + const cards = page?.sections.find((section) => section.id === 'support-coverage')?.cards ?? [] + const cardsByHref = new Map(cards.map((card) => [card.href, card])) + + expect(Object.keys(expectedSupportByLink).sort()).toEqual(bindingOverviewLinks().sort()) + expect( + bindingOverviewLinks().filter((href) => { + const card = cardsByHref.get(href) + return ( + !card || + !card.labelTooltip || + card.label !== expectedSupportByLink[href] || + card.body.trim().length === 0 + ) + }) + ).toEqual([]) + }) + + test('binding overview pages spell out support levels and code-formatted config facts', () => { + expect(bindingSlugsAt(0).flatMap(bindingOverviewSupportFailures)).toEqual([]) + }) + + test('containers overview documents full local image workflow', () => { + const page = docs.find((doc) => doc.slug === 'bindings/containers') + const supportSection = page?.sections.find( + (section) => section.id === 'local-and-remote-support' + ) + const imageSection = page?.sections.find((section) => section.id === 'container-image-workflow') + const overviewText = docText('bindings/containers') + + expect(supportSection?.label).toBe('Full') + expect(supportSection?.labelTooltip).toContain('Full') + expect( + imageSection, + 'Expected Containers overview to include image workflow guidance' + ).toBeDefined() + expect(overviewText).toContain('Dockerfile') + expect(overviewText).toContain('docker build') + expect(overviewText).toContain('podman build') + expect(overviewText).toContain('localhost/devflare-api:latest') + expect(overviewText).toContain('imageBuildContext') + expect(overviewText).toContain('registry.cloudflare.com') + expect(overviewText).toContain('Docker Hub') + expect(overviewText).toContain('Amazon ECR') + expect(overviewText).toContain('wrangler containers push') + expect(overviewText).toContain('@cloudflare/containers') + expect(overviewText).toContain('getContainer') + expect(overviewText).toContain('DEVFLARE_CONTAINER_TESTS=1') + expect(overviewText).toContain('shouldSkip.containers') + expect(overviewText).toContain('offline: true') + }) + + test('container docs use current offline-first helper options', async () => { + const text = `${docsText()}\n${await readPackageReadme()}` + + expect(text).toContain('shouldSkip.containers') + expect(text).toContain('offline: true') + expect(text).not.toContain('shouldSkip.containers()') + expect(text).not.toContain('pull: false') + }) + + test('binding overview pages include application runtime usage', () => { + expect(bindingDocsMissingSection(0, 'runtime-usage')).toEqual([]) + const runtimeSections = bindingSectionTexts(0, 'runtime-usage') + expect( + runtimeSections + .filter(({ text }) => + /\b(?:bun:test|devflare\/test|createTestContext|createOfflineEnv|createMock[A-Z]|cf\.worker|env\.dispose|expect\s*\(|describe\s*\(|test\s*\()\b/.test( + text + ) + ) + .map(({ slug }) => slug) + ).toEqual([]) + expect( + runtimeSections + .filter(({ text }) => /devflare\/runtime/.test(text)) + .map(({ slug }) => slug) + .sort() + ).toEqual(bindingSlugsAt(0).sort()) + }) + + test('binding overview pages stay usage-first instead of internals-first', () => { + const forbiddenOverviewPhrases = [ + /translation layer/i, + /normaliz/i, + /Wrangler-facing/i, + /generated output/i, + /stops pretending/i, + /under the hood/i, + /quick contract/i, + /does anything louder/i + ] + const failures = bindingReaderText(0).flatMap(({ slug, text }) => { + return forbiddenOverviewPhrases + .filter((pattern) => pattern.test(text)) + .map((pattern) => `${slug}: ${pattern}`) + }) + + expect(failures).toEqual([]) + }) + + test('binding Cloudflare reference comparison lives on internals pages', () => { + expect(bindingDocsMissingSection(0, 'cloudflare-reference')).toEqual(bindingSlugsAt(0)) + expect(bindingDocsMissingSection(1, 'cloudflare-reference')).toEqual([]) + expect( + bindingSectionTexts(1, 'cloudflare-reference') + .filter(({ text }) => !text.includes('Cloudflare docs vs the Devflare layer')) + .map(({ slug }) => slug) + ).toEqual([]) + }) + + test('binding example pages are real application examples without testing content', () => { + expect(bindingDocsMissingSection(3, 'application-flow')).toEqual([]) + expect( + bindingDocsMatching( + 3, + /\b(?:bun:test|devflare\/test|createTestContext|createOfflineEnv|createMock[A-Z]|cf\.worker|env\.dispose|expect\s*\(|describe\s*\(|test\s*\(|tests?|testing|assert)\b/i + ) + ).toEqual([]) + }) + + test('binding testing pages own the testing guidance', () => { + expect(bindingDocsMissingSection(2, 'default-loop')).toEqual([]) + expect( + bindingDocsMatching( + 2, + /\b(?:bun:test|devflare\/test|createTestContext|createOfflineEnv|createMock[A-Z]|cf\.worker|env\.dispose|expect\s*\(|describe\s*\(|test\s*\()\b/ + ).sort() + ).toEqual(bindingSlugsAt(2).sort()) + }) + + test('README top-level config key list matches the root schema', async () => { + const readme = await readPackageReadme() + const actualKeys = [...Object.keys(rootConfigShape), 'env'].sort() + + expect(documentedTopLevelConfigKeys(readme)).toEqual(actualKeys) + }) + + test('README CLI command table matches the CLI command registry', async () => { + const readme = await readPackageReadme() + + expect(documentedCliCommands(readme)).toEqual([...COMMANDS].sort()) + }) + + test('cases README lists every standalone case directory', async () => { + const readme = await readCasesReadme() + const caseDirectories = standaloneCaseDirectories() + + expect(documentedCasesFromQuickReference(readme)).toEqual(caseDirectories) + expect(documentedCasesFromDetailHeadings(readme)).toEqual(caseDirectories) + }) + + test('documentation source-of-truth contract is explicit', async () => { + const packageReadme = await readPackageReadme() + const docsReadme = await readDocsAppReadme() + const packageLlm = await readPackageLlm() + + expect(packageReadme).toContain('The docs app is the authored long-form source.') + expect(docsReadme).toContain('## Documentation contribution contract') + expect(docsReadme).toContain('run `bun run devflare:docs-integrity` from the repo root') + expect(packageLlm).toContain('Do not edit this file by hand') + }) + + test('package LLM handbook matches the generated docs model', async () => { + const packageLlm = await readPackageLlm() + + expect(packageLlm).toBe(`${buildLLMDocument().trimEnd()}\n`) + }) + + test('devflare/test value exports are documented by exact name', async () => { + const testModule = await import('../../../src/test') + const text = docsText() + const missingNames = Object.keys(testModule) + .filter((name) => name !== 'default') + .filter((name) => !text.includes(`\`${name}\``)) + + expect(missingNames).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/docs/documentation-publication.test.ts b/packages/devflare/tests/unit/docs/documentation-publication.test.ts new file mode 100644 index 0000000..dcb904c --- /dev/null +++ b/packages/devflare/tests/unit/docs/documentation-publication.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' +import { docs } from '../../../../../apps/documentation/src/lib/docs/content' + +function docText(slug: string): string { + const doc = docs.find((candidate) => candidate.slug === slug) + expect(doc, `Expected docs to include ${slug}`).toBeDefined() + return JSON.stringify(doc) +} + +function expectPageNotPublished(slug: string): void { + expect(docs.some((doc) => doc.slug === slug)).toBe(false) + expect(docs.some((doc) => doc.aliases?.includes(slug))).toBe(false) +} + +describe('documentation publication boundaries', () => { + test('recipe-first docs architecture pages exist', () => { + const requiredSlugs = [ + 'first-route-tree', + 'first-unit-test', + 'first-bindings', + 'deploy-and-preview', + 'feature-index', + 'runtime-context-internals', + 'runtime-handler-styles', + 'test-helper-reference', + 'deploy-command-recipes', + 'docs-release-gates', + 'bridge-architecture-internals' + ] + + expect(requiredSlugs.filter((slug) => !docs.some((doc) => doc.slug === slug))).toEqual([]) + }) + + test('runtime context usage page keeps runtime internals on the internals page', () => { + expect(docText('runtime-context')).not.toContain('AsyncLocalStorage') + expect(docText('runtime-context')).not.toContain('runWithEventContext') + expect(docText('runtime-context-internals')).toContain('AsyncLocalStorage') + expect(docText('runtime-context-internals')).toContain('runWithEventContext') + }) + + test('removed docs pages are not published', () => { + expectPageNotPublished('case-catalog') + expectPageNotPublished('recipe-packs') + expectPageNotPublished('binding-chooser') + expectPageNotPublished('learn-from-real-tests') + expectPageNotPublished('docs-landing-paths') + }) + + test('feature support matrix snapshot covers the main local and remote support lanes', () => { + const featureIndex = docs.find((doc) => doc.slug === 'feature-index') + const matrixTable = featureIndex?.sections.find((section) => section.id === 'matrix')?.table + const matrixRows = matrixTable?.rows ?? [] + const rowLabels = matrixRows.map((row) => row[0]).sort() + + expect(matrixTable?.layout).toBe('wide') + expect(matrixTable?.headers).toEqual([ + 'Feature', + 'Support', + 'Cloudflare boundary', + 'Test helper', + 'Preview lifecycle', + 'Docs' + ]) + expect(rowLabels).toEqual( + [ + 'Browser Rendering', + 'Containers', + 'D1', + 'Durable Objects', + 'Email', + 'Hyperdrive', + 'Images', + 'KV', + 'Media Transformations', + 'Queues', + 'R2', + 'Route tree', + 'Scheduled', + 'Secrets Store', + 'Tail Workers', + 'Vectorize', + 'Worker Loaders', + 'Workers AI', + 'Workflows' + ].sort() + ) + expect(matrixRows.every((row) => row.length === 6)).toBe(true) + expect([...new Set(matrixRows.map((row) => row[1]))].sort()).toEqual(['Full', 'Remote']) + }) +}) diff --git a/packages/devflare/tests/unit/docs/documentation-voice.test.ts b/packages/devflare/tests/unit/docs/documentation-voice.test.ts new file mode 100644 index 0000000..262d7ba --- /dev/null +++ b/packages/devflare/tests/unit/docs/documentation-voice.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +interface DocumentationFile { + path: string + content: string +} + +const bannedPhrases = [ + 'first-class', + 'one repeatable shape', + 'config, runtime usage, testing, local behavior, and remote boundaries', + 'config, runtime usage, tests, local behavior, preview lifecycle, and boundary notes', + 'copy the config, use the generated', + 'owns the details' +] + +function workspacePath(...segments: string[]): string { + return join(import.meta.dir, '..', '..', '..', '..', '..', ...segments) +} + +function documentationRoots(): string[] { + return [ + workspacePath('apps', 'documentation', 'src', 'lib', 'docs', 'content'), + workspacePath('apps', 'documentation', 'src', 'routes'), + workspacePath('apps', 'documentation', 'static', 'LLM.md'), + workspacePath('apps', 'documentation', 'static', 'LLM.txt'), + workspacePath('apps', 'documentation', 'README.md'), + workspacePath('packages', 'devflare', 'README.md'), + workspacePath('packages', 'devflare', 'LLM.md') + ] +} + +function readDocumentationFile(path: string): DocumentationFile { + return { path, content: readFileSync(path, 'utf8') } +} + +function collectDocumentationFiles(root: string): DocumentationFile[] { + const stats = statSync(root) + if (stats.isFile()) { + return [readDocumentationFile(root)] + } + + const files: DocumentationFile[] = [] + const pending = [root] + + while (pending.length > 0) { + const current = pending.pop() + if (current === undefined) { + break + } + + for (const entry of readdirSync(current)) { + const entryPath = join(current, entry) + const entryStats = statSync(entryPath) + + if (entryStats.isDirectory()) { + pending.push(entryPath) + continue + } + + if (/\.(md|svelte|ts)$/.test(entryPath)) { + files.push(readDocumentationFile(entryPath)) + } + } + } + + return files +} + +function readDocumentationFiles(): DocumentationFile[] { + return documentationRoots() + .filter((root) => existsSync(root)) + .flatMap((root) => collectDocumentationFiles(root)) +} + +describe('documentation voice', () => { + test('avoids generated-sounding stock phrases', () => { + const files = readDocumentationFiles() + const matches = files.flatMap((file) => + bannedPhrases.flatMap((phrase) => + file.content.toLowerCase().includes(phrase) + ? [`${file.path.replace(/\\/g, '/')}: ${phrase}`] + : [] + ) + ) + + expect(matches).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/docs/navigation-links.test.ts b/packages/devflare/tests/unit/docs/navigation-links.test.ts new file mode 100644 index 0000000..4f84edd --- /dev/null +++ b/packages/devflare/tests/unit/docs/navigation-links.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parseInlineText } from '../../../../../apps/documentation/src/lib/components/content/inline' + +async function readHomeNextSource(): Promise { + return readFile( + join( + import.meta.dir, + '..', + '..', + '..', + '..', + '..', + 'apps', + 'documentation', + 'src', + 'lib', + 'components', + 'home', + 'HomeNext.svelte' + ), + 'utf8' + ) +} + +describe('documentation navigation links', () => { + test('parses inline markdown links alongside code spans', () => { + expect(parseInlineText('Open [First worker](/docs/first-worker) then `devflare dev`')).toEqual([ + { kind: 'text', value: 'Open ' }, + { kind: 'link', value: 'First worker', href: '/docs/first-worker' }, + { kind: 'text', value: ' then ' }, + { kind: 'code', value: 'devflare dev' } + ]) + }) + + test('home page owns the moved docs landing paths and route tree example', async () => { + const source = await readHomeNextSource() + const pathSlugs = [ + 'first-worker', + 'first-unit-test', + 'first-route-tree', + 'http-routing', + 'first-bindings', + 'bindings/kv', + 'test-helper-reference', + 'deploy-and-preview', + 'deploy-command-recipes', + 'feature-index', + 'binding-testing-guides' + ] + + expect(source).toContain('Next: Do what matters') + expect(source).toContain('A route-tree path you can copy after the first worker runs') + expect(source).toContain('workerOnlyRecipeFiles') + expect(pathSlugs.every((slug) => source.includes(`docPath('${slug}')`))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/docs/social-cards.test.ts b/packages/devflare/tests/unit/docs/social-cards.test.ts new file mode 100644 index 0000000..a5b4f46 --- /dev/null +++ b/packages/devflare/tests/unit/docs/social-cards.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import documentationPackageJson from '../../../../../apps/documentation/package.json' +import { docs } from '../../../../../apps/documentation/src/lib/docs/content' +import { + DEFAULT_SOCIAL_DESCRIPTION, + DEFAULT_SOCIAL_CARD_TITLE, + getSocialCardPath, + getSocialTitle +} from '../../../../../apps/documentation/src/lib/site/social' +import { + createSocialCardPages, + generateSocialCards, + renderSocialCardHtml +} from '../../../../../apps/documentation/scripts/social-cards' + +const transparentLogoSvg = + '' + +function readPngDimensions(bytes: Buffer): { width: number; height: number } { + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20) + } +} + +describe('documentation social cards', () => { + test('uses generated page-specific social card paths', () => { + const whyDevflare = docs.find((doc) => doc.slug === 'what-devflare-is') + + expect(getSocialCardPath(undefined)).toBe('/social-cards/home.png') + expect(getSocialCardPath(whyDevflare)).toBe('/social-cards/docs/what-devflare-is.png') + expect(getSocialCardPath(whyDevflare)).not.toBe('/devflare-social-card.png') + }) + + test('creates one home card and one card per published docs page', () => { + const pages = createSocialCardPages() + const paths = pages.map((page) => page.path) + + expect(pages).toHaveLength(docs.length + 1) + expect(paths).toContain('/social-cards/home.png') + expect(paths).toContain('/social-cards/docs/what-devflare-is.png') + expect(new Set(paths).size).toBe(paths.length) + expect(pages.find((page) => page.path === '/social-cards/home.png')).toMatchObject({ + title: DEFAULT_SOCIAL_CARD_TITLE, + description: DEFAULT_SOCIAL_DESCRIPTION + }) + }) + + test('renders the cleaner Svelte card template with browser-owned badge spacing', async () => { + const html = await renderSocialCardHtml( + { + path: '/social-cards/docs/what-devflare-is.png', + title: getSocialTitle(docs.find((doc) => doc.slug === 'what-devflare-is')), + description: + 'Devflare gives you one clearer story for config, worker compilation, local development, runtime helpers, testing, and deploy flows.' + }, + { logoSvg: transparentLogoSvg, npmLogoPng: Buffer.alloc(0) } + ) + + expect(html).toContain('DEVFLARE DOCS') + expect(html).toContain('social-card-grid') + expect(html).toContain('social-card-blob') + expect(html).toContain('social-card-badge') + expect(html).toContain('Refzlund/devflare') + expect(html).toContain('npmjs') + expect(html).toContain('Local-first toolkit for Cloudflare Workers') + expect(html).toContain('display: inline-flex') + expect(html).toContain('gap: 10px') + expect(html).toContain('padding: 0 10px') + expect(html).toContain('border-radius: 7px') + expect(html).not.toContain('>df<') + expect(html.match(/DEVFLARE DOCS/g) ?? []).toHaveLength(1) + expect(html).not.toContain('devflare docs') + expect(html).not.toContain('textLength') + expect(html).not.toContain('lengthAdjust') + }) + + test('uses a standalone Svelte compiler config for social cards', async () => { + const source = await readFile( + new URL('../../../../../apps/documentation/scripts/social-cards.ts', import.meta.url), + 'utf8' + ) + + expect(source).toContain('configFile: false') + }) + + test('generates social cards in docs runtime workflows without slowing dependency install', () => { + const scripts = documentationPackageJson.scripts + + for (const scriptName of ['dev', 'build', 'deploy', 'deploy:preview', 'check', 'check:watch']) { + expect(scripts[scriptName as keyof typeof scripts]).toContain('social:generate') + } + + expect(scripts.prepare).not.toContain('social:generate') + }) + + test('generates crawlable 1200x630 png files into the requested output directory', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'devflare-social-cards-')) + const pngBytes = Buffer.from( + '89504e470d0a1a0a0000000d49484452000004b00000027608060000006255f19e000000017352474200aece1ce90000000467414d410000b18f0bfc6105000000097048597300000ec300000ec301c76fa8640000001849444154785eedc1010d000000c2a0f74f6d0e37a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080810b9b4300019702a3010000000049454e44ae426082', + 'hex' + ) + + try { + const result = await generateSocialCards({ + logoSvg: transparentLogoSvg, + outputDir, + renderPng: async ({ outputPath }) => { + await Bun.write(outputPath, pngBytes) + }, + pages: [ + { + path: '/social-cards/docs/what-devflare-is.png', + title: 'Why Devflare - Devflare Docs', + description: 'A concise page-specific preview.' + } + ] + }) + const outputPath = join(outputDir, 'docs', 'what-devflare-is.png') + const bytes = await readFile(outputPath) + const metadata = readPngDimensions(bytes) + const file = await stat(outputPath) + + expect(result.outputFiles).toEqual([outputPath]) + expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a') + expect(metadata).toEqual({ width: 1200, height: 630 }) + expect(file.size).toBeGreaterThan(100) + } finally { + await rm(outputDir, { recursive: true, force: true }) + } + }) + + test('reuses generated cards when inputs have not changed', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'devflare-social-cards-cache-')) + let renderCount = 0 + const page = { + path: '/social-cards/docs/what-devflare-is.png', + title: 'Why Devflare - Devflare Docs', + description: 'A concise page-specific preview.' + } + + try { + await generateSocialCards({ + logoSvg: transparentLogoSvg, + outputDir, + pages: [page], + renderPng: async ({ outputPath }) => { + renderCount += 1 + await Bun.write(outputPath, 'png placeholder') + } + }) + + await generateSocialCards({ + logoSvg: transparentLogoSvg, + outputDir, + pages: [page], + renderPng: async () => { + renderCount += 1 + throw new Error('cached social card should not render again') + } + }) + + expect(renderCount).toBe(1) + } finally { + await rm(outputDir, { recursive: true, force: true }) + } + }) + + test('can force social card regeneration when cached inputs match', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'devflare-social-cards-force-')) + let renderCount = 0 + const page = { + path: '/social-cards/docs/what-devflare-is.png', + title: 'Why Devflare - Devflare Docs', + description: 'A concise page-specific preview.' + } + + try { + await generateSocialCards({ + logoSvg: transparentLogoSvg, + outputDir, + pages: [page], + renderPng: async ({ outputPath }) => { + renderCount += 1 + await Bun.write(outputPath, 'png placeholder') + } + }) + + await generateSocialCards({ + force: true, + logoSvg: transparentLogoSvg, + outputDir, + pages: [page], + renderPng: async ({ outputPath }) => { + renderCount += 1 + await Bun.write(outputPath, 'png placeholder') + } + }) + + expect(renderCount).toBe(2) + } finally { + await rm(outputDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/devflare/tests/unit/docs/support-stances.test.ts b/packages/devflare/tests/unit/docs/support-stances.test.ts new file mode 100644 index 0000000..d38ab2e --- /dev/null +++ b/packages/devflare/tests/unit/docs/support-stances.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +function readPackageReadme(): string { + return readFileSync(join(import.meta.dir, '..', '..', '..', 'README.md'), 'utf8') +} + +describe('documented Cloudflare product stances', () => { + test('documents AutoRAG as the legacy name for AI Search bindings', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### AutoRAG migration stance') + expect(readme).toContain('previous `env.AI.autorag()` binding') + expect(readme).toContain('Use `bindings.aiSearchNamespaces` or `bindings.aiSearch`') + }) + + test('documents AI Gateway as an AI binding method surface', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### AI Gateway binding methods') + expect(readme).toContain('AI Gateway does not use a separate Wrangler binding') + expect(readme).toContain( + '`env.AI.gateway(id)` exposes `patchLog()`, `getLog()`, `getUrl()`, and `run()`' + ) + }) + + test('documents Browser Run as the current Browser Rendering product name', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Browser Run product boundary') + expect(readme).toContain('Browser Run is the current product name for Browser Rendering') + expect(readme).toContain('Devflare does not manage Live View URLs, Human in the Loop handoff') + }) + + test('documents native offline-first Containers testing support', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Containers local testing') + expect(readme).toContain('Devflare supports native top-level `containers` config') + expect(readme).toContain('Devflare container tests are offline-first by default') + expect(readme).toContain('Set `DEVFLARE_CONTAINER_TESTS=1`') + expect(readme).toContain( + 'Containers have full local support when Docker or Podman is available' + ) + expect(readme).toContain('Cloudflare still owns the deployed Containers control plane') + }) + + test('documents Cloudflare Builds as CI/CD orchestration', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Cloudflare Builds stance') + expect(readme).toContain( + 'Cloudflare Builds is CI/CD orchestration, not a Worker runtime binding' + ) + expect(readme).toContain('Devflare does not connect Git repositories, manage build hooks') + }) + + test('documents Workers for Platforms lifecycle boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Workers for Platforms lifecycle stance') + expect(readme).toContain( + 'Devflare supports dispatch namespace bindings, not the tenant Worker control plane' + ) + expect(readme).toContain('Devflare does not upload user Workers, manage Worker metadata') + }) + + test('documents Workflows local simulation boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Workflows local simulation stance') + expect(readme).toContain('Workflows have full local support through Miniflare wiring') + expect(readme).toContain( + 'Use deployed or Wrangler-backed tests for production Workflow lifecycle behavior' + ) + }) + + test('documents Pipelines source and sink lifecycle boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Pipelines source and sink lifecycle stance') + expect(readme).toContain('Pipelines local tests are useful for producer-code assertions') + expect(readme).toContain( + 'Devflare does not create streams, pipelines, SQL transformations, sinks, or R2 buckets' + ) + }) + + test('documents Images transformation and testability boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Images transformation testability stance') + expect(readme).toContain('Images have full local support for Worker transformation flows') + expect(readme).toContain( + 'Devflare does not provision hosted Images storage, variants, signed URLs, or custom delivery rules' + ) + }) + + test('documents Media Transformations local shim boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Media Transformations local shim stance') + expect(readme).toContain('Media Transformations have full local support for Worker call chains') + expect(readme).toContain( + 'Devflare does not configure zone-level transformation enablement, source origins, signed URL policy, cache behavior, or billing controls' + ) + }) + + test('documents Artifacts persistence and deployment boundaries', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Artifacts persistence and deployment stance') + expect(readme).toContain('Artifacts pure mocks are in-memory and process-local') + expect(readme).toContain( + 'Devflare does not create Artifacts namespaces, persist local Git repositories, or emulate Git-over-HTTPS remotes' + ) + }) + + test('documents preview resource lifecycle policy for newer bindings', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Preview resource lifecycle policy') + expect(readme).toContain( + 'Devflare preview provisioning is intentionally limited to KV, D1, R2, Queues, Vectorize, and the documented Hyperdrive reuse/resolve paths' + ) + expect(readme).toContain( + 'Preview cleanup does not delete Workflows, Pipelines, Images, Media Transformations, Artifacts, AI Search, AI Gateway, Browser Run, Containers, Secrets Store, mTLS certificates, or dispatch namespace resources' + ) + }) + + test('documents cross-feature implementation decisions', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Cross-feature implementation decisions') + expect(readme).toContain('Remote mode decisions are per feature, not global') + expect(readme).toContain('Generated types are emitted only for native binding keys') + expect(readme).toContain( + 'Test helpers exist when Devflare provides a deterministic local mock or useful pure assertion surface' + ) + expect(readme).toContain( + 'Every native binding documented above includes a minimal config and Env usage example' + ) + expect(readme).toContain( + 'Move from `wrangler.passthrough` to native config when a binding appears in the native list' + ) + expect(readme).toContain( + 'Cloudflare dependency CI targets the pinned current Wrangler, Miniflare, and workers-types majors documented in Cloudflare toolchain support' + ) + }) + + test('documents offline-first testing matrix and config-derived env helpers', () => { + const readme = readPackageReadme() + + expect(readme).toContain('### Offline-first testing support matrix') + expect(readme).toContain( + '`createOfflineEnv(config, fixtures)` derives a deterministic pure-test `env` from Devflare config' + ) + expect(readme).toContain( + 'Offline-native means Devflare or Miniflare can run a useful local simulator' + ) + expect(readme).toContain( + 'Offline-fixture means Devflare provides an explicit in-memory or handler-backed mock' + ) + expect(readme).toContain('Remote-boundary means meaningful behavior lives in Cloudflare') + expect(readme).toContain( + '`shouldSkip.aiSearch`, `shouldSkip.aiGateway`, `shouldSkip.mtlsCertificates`, `shouldSkip.artifacts`, and `shouldSkip.builds`' + ) + expect(readme).toContain( + 'real Workers AI inference, Vectorize search semantics, AI Search indexing/ranking/crawling, final Media Transformations codec fidelity, mTLS certificate presentation, Artifacts Git remotes, Browser Run live/HITL/recordings, Cloudflare Builds, or the deployed Containers control plane' + ) + }) +}) diff --git a/packages/devflare/tests/unit/github-feedback-action.test.ts b/packages/devflare/tests/unit/github-feedback-action.test.ts new file mode 100644 index 0000000..4388bfb --- /dev/null +++ b/packages/devflare/tests/unit/github-feedback-action.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + main + // @ts-ignore - JS module with no declarations +} from '../../../../.github/actions/devflare-github-feedback/index.js' + +const originalFetch = globalThis.fetch +const originalEnvironment = { ...process.env } +const temporaryDirectories = new Set() + +function restoreEnvironment(): void { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnvironment)) { + delete process.env[key] + } + } + + for (const [key, value] of Object.entries(originalEnvironment)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +function createGitHubJsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json' + } + }) +} + +function writeOutputFile(tempDir: string): string { + const outputPath = join(tempDir, 'github-output.txt') + writeFileSync(outputPath, '', 'utf-8') + return outputPath +} + +function setCommentModeEnvironment(outputPath: string): void { + process.env.GITHUB_REPOSITORY = 'Refzlund/devflare' + process.env.GITHUB_OUTPUT = outputPath + process.env.INPUT_GITHUB_TOKEN = 'ghs_test_token' + process.env.INPUT_MODE = 'comment' + process.env.INPUT_OPERATION = 'report' + process.env.INPUT_STATUS = 'success' + process.env.INPUT_TITLE = 'Testing PR preview' + process.env.INPUT_COMMENT_KEY = 'testing-preview' + process.env.INPUT_PR_NUMBER = '1' +} + +afterEach(() => { + globalThis.fetch = originalFetch + restoreEnvironment() + + for (const directory of temporaryDirectories) { + rmSync(directory, { recursive: true, force: true }) + } + temporaryDirectories.clear() +}) + +describe('devflare-github-feedback action', () => { + test('skips PR comment failures caused by integration permission 403s by default', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'devflare-github-feedback-')) + temporaryDirectories.add(tempDir) + const outputPath = writeOutputFile(tempDir) + setCommentModeEnvironment(outputPath) + + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if ( + method === 'GET' && + url.endsWith('/repos/Refzlund/devflare/issues/1/comments?per_page=100') + ) { + return createGitHubJsonResponse([], 200) + } + + if (method === 'POST' && url.endsWith('/repos/Refzlund/devflare/issues/1/comments')) { + return createGitHubJsonResponse( + { + message: 'Resource not accessible by integration', + status: '403' + }, + 403 + ) + } + + throw new Error(`Unexpected fetch request: ${method} ${url}`) + }) as unknown as typeof fetch + + await expect(main()).resolves.toBeUndefined() + + const output = readFileSync(outputPath, 'utf-8') + expect(output).toContain('comment-id=') + expect(output).toContain('pr-number=1') + }) + + test('can still fail on comment permission 403s when the ignore flag is disabled', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'devflare-github-feedback-')) + temporaryDirectories.add(tempDir) + const outputPath = writeOutputFile(tempDir) + setCommentModeEnvironment(outputPath) + process.env.INPUT_IGNORE_COMMENT_PERMISSION_ERRORS = 'false' + + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if ( + method === 'GET' && + url.endsWith('/repos/Refzlund/devflare/issues/1/comments?per_page=100') + ) { + return createGitHubJsonResponse([], 200) + } + + if (method === 'POST' && url.endsWith('/repos/Refzlund/devflare/issues/1/comments')) { + return createGitHubJsonResponse( + { + message: 'Resource not accessible by integration', + status: '403' + }, + 403 + ) + } + + throw new Error(`Unexpected fetch request: ${method} ${url}`) + }) as unknown as typeof fetch + + await expect(main()).rejects.toThrow('Resource not accessible by integration') + }) + + test('does not include production URLs in preview PR comments even when provided', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'devflare-github-feedback-')) + temporaryDirectories.add(tempDir) + const outputPath = writeOutputFile(tempDir) + setCommentModeEnvironment(outputPath) + process.env.INPUT_DEPLOYMENT_KIND = 'preview' + process.env.INPUT_PREVIEW_URL = 'https://devflare-docs-pr-1.refz.workers.dev' + process.env.INPUT_PRODUCTION_URL = 'https://devflare-docs.refz.workers.dev' + + let postedCommentBody = '' + + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if ( + method === 'GET' && + url.endsWith('/repos/Refzlund/devflare/issues/1/comments?per_page=100') + ) { + return createGitHubJsonResponse([], 200) + } + + if (method === 'POST' && url.endsWith('/repos/Refzlund/devflare/issues/1/comments')) { + postedCommentBody = String(init?.body ?? '') + return createGitHubJsonResponse({ id: 123 }, 201) + } + + throw new Error(`Unexpected fetch request: ${method} ${url}`) + }) as unknown as typeof fetch + + await expect(main()).resolves.toBeUndefined() + + expect(postedCommentBody).toContain('Preview URL: [https://devflare-docs-pr-1.refz.workers.dev](https://devflare-docs-pr-1.refz.workers.dev)') + expect(postedCommentBody).not.toContain('Production URL') + expect(postedCommentBody).not.toContain('https://devflare-docs.refz.workers.dev') + }) +}) diff --git a/packages/devflare/tests/unit/package-surface.test.ts b/packages/devflare/tests/unit/package-surface.test.ts new file mode 100644 index 0000000..f9e8d50 --- /dev/null +++ b/packages/devflare/tests/unit/package-surface.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from 'bun:test' + +describe('main barrel public surface', () => { + test('exports expected public names', async () => { + const mod = await import('../../src/index.ts') + const expected = [ + 'defineConfig', + 'preview', + 'loadConfig', + 'loadResolvedConfig', + 'compileConfig', + 'ref', + 'workerName', + 'runCli', + 'parseArgs', + 'env', + 'durableObject', + 'getDurableObjectOptions', + 'configSchema', + 'ConfigNotFoundError', + 'ConfigValidationError', + 'ConfigResourceResolutionError', + 'default' + ] + for (const name of expected) { + expect(name in mod).toBe(true) + } + }) + + test('does not expose internal bridge/test/transform helpers', async () => { + const mod = await import('../../src/index.ts') + const removed = [ + 'setBindingHints', + 'createEnvProxy', + 'initEnv', + 'BridgeClient', + 'getClient', + 'startMiniflare', + 'getMiniflare', + 'stopMiniflare', + 'gateway', + 'createTestContext', + 'createMockKV', + 'createMockD1', + 'createBridgeTestContext', + 'testEnv', + 'findDurableObjectClasses', + 'transformDurableObject', + 'transformWorkerEntrypoint' + ] + for (const name of removed) { + expect(name in mod).toBe(false) + } + }) + + test('removed names remain importable from devflare/test subpath', async () => { + const testMod = await import('../../src/test/index.ts') + expect('createTestContext' in testMod).toBe(true) + expect('createBridgeTestContext' in testMod).toBe(false) + }) + + test('bridge internals remain importable from bridge subpath', async () => { + const bridgeMod = await import('../../src/bridge/index.ts') + expect('startMiniflare' in bridgeMod).toBe(true) + expect('BridgeClient' in bridgeMod).toBe(true) + expect('createEnvProxy' in bridgeMod).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/quality/package-build.test.ts b/packages/devflare/tests/unit/quality/package-build.test.ts new file mode 100644 index 0000000..65978cd --- /dev/null +++ b/packages/devflare/tests/unit/quality/package-build.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +function workspacePath(...segments: string[]): string { + return join(import.meta.dir, '..', '..', '..', '..', '..', ...segments) +} + +describe('package build hygiene', () => { + test('cleans ignored dist output before emitting publishable package files', () => { + const packageJson = JSON.parse( + readFileSync(workspacePath('packages/devflare/package.json'), 'utf8') + ) as { + scripts?: Record + } + + expect(packageJson.scripts?.['clean:dist']).toBe('bun ./scripts/clean-dist.ts') + expect(packageJson.scripts?.build).toStartWith('bun run clean:dist && bun build ') + expect(existsSync(workspacePath('packages/devflare/scripts/clean-dist.ts'))).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/quality/source-file-size.test.ts b/packages/devflare/tests/unit/quality/source-file-size.test.ts new file mode 100644 index 0000000..483172a --- /dev/null +++ b/packages/devflare/tests/unit/quality/source-file-size.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const MAX_EDITABLE_SOURCE_LINES = 1000 + +const ignoredEditableSourcePaths = [ + /(^|\/)(?:node_modules|dist|coverage|\.svelte-kit|\.wrangler)(?:\/|$)/, + /(^|\/)(?:generated|__generated__)(?:\/|$)/, + /\.generated\.(?:ts|svelte)$/ +] + +function workspacePath(...segments: string[]): string { + return join(import.meta.dir, '..', '..', '..', '..', '..', ...segments) +} + +function trackedSourceFiles(): string[] { + const result = spawnSync( + 'git', + ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.ts', '*.svelte'], + { + cwd: workspacePath(), + encoding: 'buffer' + } + ) + + expect(result.status, result.stderr.toString('utf8')).toBe(0) + + return result.stdout + .toString('utf8') + .split('\0') + .filter(Boolean) + .filter((path) => !ignoredEditableSourcePaths.some((pattern) => pattern.test(path))) +} + +function lineCount(path: string): number { + return readFileSync(workspacePath(path), 'utf8').split(/\r\n|\n|\r/).length +} + +describe('source file size', () => { + test('tracked TypeScript and Svelte source files stay below the reviewable size ceiling', () => { + const oversizedFiles = trackedSourceFiles() + .map((path) => ({ path, lines: lineCount(path) })) + .filter(({ lines }) => lines > MAX_EDITABLE_SOURCE_LINES) + .sort((a, b) => b.lines - a.lines) + + expect(oversizedFiles).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/runtime/context.test.ts b/packages/devflare/tests/unit/runtime/context.test.ts new file mode 100644 index 0000000..dbe040a --- /dev/null +++ b/packages/devflare/tests/unit/runtime/context.test.ts @@ -0,0 +1,327 @@ +// ============================================================================= +// Runtime Context Tests โ€” ASL-based context management +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + createDurableObjectAlarmEvent, + createDurableObjectFetchEvent, + createEmailEvent, + createFetchEvent, + createQueueEvent, + createScheduledEvent, + createTailEvent, + getDurableObjectAlarmEvent, + getDurableObjectEvent, + getDurableObjectFetchEvent, + getEmailEvent, + getFetchEvent, + getQueueEvent, + getScheduledEvent, + getTailEvent, + runWithContext, + runWithEventContext, + getContext, + getContextOrNull, + ContextUnavailableError +} from '../../../src/runtime/context' + +/** Helper to create a mock ExecutionContext */ +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } as ExecutionContext +} + +function createMockState(): DurableObjectState { + return { + storage: {} as DurableObjectStorage, + waitUntil: () => { }, + blockConcurrencyWhile: async (callback: () => Promise) => callback() + } as unknown as DurableObjectState +} + +function createMockQueueBatch(): MessageBatch<{ value: string }> { + return { + queue: 'test-queue', + metadata: { + metrics: { + backlogCount: 0, + backlogBytes: 0 + } + }, + messages: [ + { + id: 'msg-1', + timestamp: new Date('2026-03-17T00:00:00.000Z'), + body: { value: 'queued' }, + attempts: 1, + ack() { }, + retry() { } + } as Message<{ value: string }> + ], + ackAll() { }, + retryAll() { } + } as MessageBatch<{ value: string }> +} + +function createMockEmailMessage(): ForwardableEmailMessage { + return { + from: 'sender@example.com', + to: 'worker@example.com', + headers: new Headers(), + raw: new ReadableStream({ + start(controller) { + controller.close() + } + }), + rawSize: 0, + setReject() { }, + forward: async () => { }, + reply: async () => { } + } as unknown as ForwardableEmailMessage +} + +describe('runWithContext', () => { + test('runs function with context available', () => { + const mockEnv = { KV: {} } + const mockCtx = createMockCtx() + + const result = runWithContext(mockEnv, mockCtx, null, () => { + const ctx = getContext() + return ctx.env + }) + + expect(result).toBe(mockEnv) + }) + + test('provides request in context', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + const mockRequest = new Request('https://example.com') + + runWithContext(mockEnv, mockCtx, mockRequest, () => { + const ctx = getContext() + expect(ctx.request).toBe(mockRequest) + }) + }) + + test('establishes Durable Object alarm events automatically when using runWithContext', () => { + const mockEnv = { TEST: true } + const mockState = createMockState() + + runWithContext(mockEnv, mockState, null, () => { + expect(getDurableObjectEvent().type).toBe('durable-object-alarm') + expect(getDurableObjectAlarmEvent().state).toBe(mockState) + }, 'durable-object-alarm') + }) + + test('initializes empty locals', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + const ctx = getContext() + expect(ctx.locals).toEqual({}) + }) + }) + + test('preserves context through async operations', async () => { + const mockEnv = { value: 42 } + const mockCtx = createMockCtx() + + const result = await runWithContext(mockEnv, mockCtx, null, async () => { + await Promise.resolve() + const ctx = getContext() + return (ctx.env as { value: number }).value + }) + + expect(result).toBe(42) + }) + + test('nested contexts use inner context', () => { + const outerEnv = { level: 'outer' } + const innerEnv = { level: 'inner' } + const mockCtx = createMockCtx() + + runWithContext(outerEnv, mockCtx, null, () => { + expect((getContext().env as { level: string }).level).toBe('outer') + + runWithContext(innerEnv, mockCtx, null, () => { + expect((getContext().env as { level: string }).level).toBe('inner') + }) + + expect((getContext().env as { level: string }).level).toBe('outer') + }) + }) +}) + +describe('getContext', () => { + test('throws when called outside context', () => { + expect(() => getContext()).toThrow(ContextUnavailableError) + }) + + test('error message is helpful', () => { + try { + getContext() + } catch (e) { + expect(e).toBeInstanceOf(ContextUnavailableError) + const error = e as ContextUnavailableError + expect(error.message).toContain('Context not available') + expect(error.message).toContain('nodejs_compat') + } + }) +}) + +describe('getContextOrNull', () => { + test('returns null when called outside context', () => { + const result = getContextOrNull() + expect(result).toBeNull() + }) + + test('returns context when available', () => { + const mockEnv = { test: true } + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + const result = getContextOrNull() + expect(result).not.toBeNull() + expect((result?.env as { test: boolean }).test).toBe(true) + }) + }) +}) + +describe('event-first context accessors', () => { + test('establishes fetch events through AsyncLocalStorage', () => { + const mockEnv = { CACHE: true } + const mockCtx = createMockCtx() + const request = new Request('https://example.com/users/123') + const fetchEvent = createFetchEvent(request, mockEnv, mockCtx, { + params: { id: '123' } + }) + + runWithEventContext(fetchEvent, () => { + expect(getFetchEvent()).toBe(fetchEvent) + expect(getFetchEvent().request).toBe(request) + expect(getFetchEvent().params.id).toBe('123') + expect(fetchEvent.url).toBeInstanceOf(URL) + expect(fetchEvent.url.href).toBe('https://example.com/users/123') + expect(fetchEvent.url.pathname).toBe('/users/123') + expect(fetchEvent.request.url).toBe('https://example.com/users/123') + expect(Object.keys(fetchEvent)).toContain('url') + expect(Reflect.getOwnPropertyDescriptor(fetchEvent, 'url')?.value).toBeInstanceOf(URL) + expect((Reflect.getOwnPropertyDescriptor(fetchEvent, 'url')?.value as URL | undefined)?.href).toBe('https://example.com/users/123') + }) + }) + + test('exposes queue, scheduled, email, tail, and Durable Object getters', () => { + const mockEnv = { TEST: true } + const mockCtx = createMockCtx() + const mockState = createMockState() + const batch = createMockQueueBatch() + const controller = { + cron: '0 * * * *', + scheduledTime: Date.now(), + noRetry() { } + } as ScheduledController + const emailMessage = createMockEmailMessage() + const traceItems = [{ scriptName: 'worker', outcome: 'ok', eventTimestamp: Date.now() } as TraceItem] + const doRequest = new Request('https://example.com/do') + + runWithEventContext(createQueueEvent(batch, mockEnv, mockCtx), () => { + expect(getQueueEvent().batch).toBe(batch) + expect(getQueueEvent().messages).toHaveLength(1) + }) + + runWithEventContext(createScheduledEvent(controller, mockEnv, mockCtx), () => { + expect(getScheduledEvent().controller.cron).toBe('0 * * * *') + }) + + runWithEventContext(createEmailEvent(emailMessage, mockEnv, mockCtx), () => { + expect(getEmailEvent().message.from).toBe('sender@example.com') + expect(getEmailEvent().from).toBe('sender@example.com') + }) + + runWithEventContext(createTailEvent(traceItems, mockEnv, mockCtx), () => { + expect(getTailEvent().events).toBe(traceItems) + expect(getTailEvent()).toHaveLength(1) + }) + + runWithEventContext(createDurableObjectFetchEvent(doRequest, mockEnv, mockState), () => { + expect(getDurableObjectEvent().type).toBe('durable-object-fetch') + expect(getDurableObjectFetchEvent().request).toBe(doRequest) + expect(getDurableObjectFetchEvent().state).toBe(mockState) + }) + + runWithEventContext(createDurableObjectAlarmEvent(mockEnv, mockState), () => { + expect(getDurableObjectEvent().type).toBe('durable-object-alarm') + expect(getDurableObjectAlarmEvent().state).toBe(mockState) + }) + }) + + test('safe accessors return null outside the matching surface', () => { + expect(getFetchEvent.safe()).toBeNull() + expect(getQueueEvent.safe()).toBeNull() + expect(getScheduledEvent.safe()).toBeNull() + expect(getEmailEvent.safe()).toBeNull() + expect(getTailEvent.safe()).toBeNull() + expect(getDurableObjectEvent.safe()).toBeNull() + + const mockEnv = { TEST: true } + const mockCtx = createMockCtx() + const batch = createMockQueueBatch() + + runWithEventContext(createQueueEvent(batch, mockEnv, mockCtx), () => { + expect(getFetchEvent.safe()).toBeNull() + expect(() => getFetchEvent()).toThrow(ContextUnavailableError) + }) + }) +}) + +describe('locals mutation', () => { + test('allows setting locals', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + const ctx = getContext() + ctx.locals.userId = '123' + ctx.locals.role = 'admin' + + expect(ctx.locals.userId).toBe('123') + expect(ctx.locals.role).toBe('admin') + }) + }) + + test('locals persist within same context', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + const ctx1 = getContext() + ctx1.locals.value = 'set' + + const ctx2 = getContext() + expect(ctx2.locals.value).toBe('set') + }) + }) + + test('locals are isolated between contexts', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + getContext().locals.outer = true + + runWithContext(mockEnv, mockCtx, null, () => { + expect(getContext().locals.outer).toBeUndefined() + getContext().locals.inner = true + }) + + expect(getContext().locals.outer).toBe(true) + expect(getContext().locals.inner).toBeUndefined() + }) + }) +}) diff --git a/packages/devflare/tests/unit/runtime/exports.test.ts b/packages/devflare/tests/unit/runtime/exports.test.ts new file mode 100644 index 0000000..54389ec --- /dev/null +++ b/packages/devflare/tests/unit/runtime/exports.test.ts @@ -0,0 +1,205 @@ +// ============================================================================= +// Runtime Exports Tests โ€” env, vars, ctx, event, locals proxies +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { createFetchEvent, runWithContext, runWithEventContext } from '../../../src/runtime/context' +import { ContextAccessError } from '../../../src/runtime/validation' + +// Import the actual exports we'll create +import { env, vars, ctx, event, locals } from '../../../src/runtime/exports' + +/** Helper to create a mock ExecutionContext */ +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } as ExecutionContext +} + +describe('env proxy', () => { + test('throws ContextAccessError outside request handler', () => { + expect(() => (env as Record).DB).toThrow(ContextAccessError) + }) + + test('provides access to env bindings within context', () => { + const mockEnv = { DB: 'd1-instance', KV: 'kv-namespace' } + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + expect((env as Record).DB).toBe('d1-instance') + expect((env as Record).KV).toBe('kv-namespace') + }) + }) + + test('env is readonly', () => { + const mockEnv = { DB: 'original' } + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + // TypeScript should prevent this, but let's verify runtime behavior + expect(() => { + (env as Record).DB = 'modified' + }).toThrow() + }) + }) +}) + +describe('vars proxy', () => { + test('throws ContextAccessError outside request handler', () => { + expect(() => (vars as Record).mongo).toThrow(ContextAccessError) + }) + + test('provides typed runtime vars from the active env object', () => { + const mockEnv = { + mongo: { + database: 'voices' + }, + isNumber: 42 + } + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + expect((vars as Record).mongo.database).toBe('voices') + expect((vars as Record).isNumber).toBe(42) + }) + }) + + test('vars is readonly', () => { + const mockEnv = { APP_ENV: 'local' } + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + expect(() => { + (vars as Record).APP_ENV = 'production' + }).toThrow() + }) + }) +}) + +describe('ctx proxy', () => { + test('throws ContextAccessError outside request handler', () => { + expect(() => (ctx as ExecutionContext).waitUntil).toThrow(ContextAccessError) + }) + + test('provides access to ExecutionContext within context', () => { + const mockEnv = {} + const waitUntilFn = () => { } + const mockCtx: ExecutionContext = { + waitUntil: waitUntilFn, + passThroughOnException: () => { }, + props: {} + } + + runWithContext(mockEnv, mockCtx, null, () => { + expect((ctx as ExecutionContext).waitUntil).toBe(waitUntilFn) + }) + }) +}) + +describe('event proxy', () => { + test('throws ContextAccessError outside request handler', () => { + expect(() => event.request).toThrow(ContextAccessError) + }) + + test('provides access to request within context', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + const mockRequest = new Request('https://example.com/api') + + runWithContext(mockEnv, mockCtx, mockRequest, () => { + expect(event.request).toBe(mockRequest) + expect(event.request!.url).toBe('https://example.com/api') + }) + }) + + test('provides context type', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + expect(event.type).toBe('fetch') + }) + }) + + test('reflects the active event-first fetch object', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + const mockRequest = new Request('https://example.com/users/123') + const fetchEvent = createFetchEvent(mockRequest, mockEnv, mockCtx, { + params: { id: '123' } + }) + + runWithEventContext(fetchEvent, () => { + expect(event.type).toBe('fetch') + expect(event.request).toBe(mockRequest) + expect((event as unknown as { params: { id: string } }).params.id).toBe('123') + }) + }) +}) + +describe('locals proxy', () => { + test('throws ContextAccessError outside request handler', () => { + expect(() => (locals as Record).userId).toThrow(ContextAccessError) + }) + + test('provides mutable storage within context', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + runWithContext(mockEnv, mockCtx, null, () => { + ; (locals as Record).userId = '123' + ; (locals as Record).authenticated = true + + expect(locals.userId).toBe('123') + expect(locals.authenticated).toBe(true) + }) + }) + + test('locals are isolated between requests', () => { + const mockEnv = {} + const mockCtx = createMockCtx() + + // First request + runWithContext(mockEnv, mockCtx, null, () => { + ; (locals as Record).value = 'request-1' + }) + + // Second request should have fresh locals + runWithContext(mockEnv, mockCtx, null, () => { + expect((locals as Record).value).toBeUndefined() + }) + }) +}) + +describe('combined usage', () => { + test('all exports work together within same context', async () => { + const mockEnv = { API_KEY: 'secret' } + const mockRequest = new Request('https://api.example.com/users') + const waitUntilPromises: Promise[] = [] + const mockCtx: ExecutionContext = { + waitUntil: (p: Promise) => { waitUntilPromises.push(p) }, + passThroughOnException: () => { }, + props: {} + } + + await runWithContext(mockEnv, mockCtx, mockRequest, async () => { + // Access env + expect((env as Record).API_KEY).toBe('secret') + + // Use ctx + ;(ctx as ExecutionContext).waitUntil(Promise.resolve('background-task')) + + // Access event + expect(event.request!.url).toBe('https://api.example.com/users') + + // Use locals + ; (locals as Record).processedAt = Date.now() + expect(typeof locals.processedAt).toBe('number') + }) + + // Verify waitUntil was called + expect(waitUntilPromises.length).toBe(1) + }) +}) diff --git a/packages/devflare/tests/unit/runtime/middleware-detection.test.ts b/packages/devflare/tests/unit/runtime/middleware-detection.test.ts new file mode 100644 index 0000000..e07b7b7 --- /dev/null +++ b/packages/devflare/tests/unit/runtime/middleware-detection.test.ts @@ -0,0 +1,152 @@ +// ============================================================================= +// Middleware Detection Tests โ€” minification-safe handler style detection +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + defineFetchHandler, + invokeFetchHandler, + sequence, + type FetchMiddleware +} from '../../../src/runtime/middleware' +import { createFetchEvent, runWithEventContext } from '../../../src/runtime/context' + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } as ExecutionContext +} + +function createEvent(url = 'https://example.com/') { + return createFetchEvent(new Request(url), { FOO: 'bar' }, createMockCtx()) +} + +function simulateMinification any>(fn: T): T { + Object.defineProperty(fn, 'name', { value: 'o', configurable: true }) + Object.defineProperty(fn, 'toString', { value: () => '', configurable: true }) + return fn +} + +describe('middleware detection', () => { + test('sequence() produces a resolve-style handler that runs middlewares in order plus a leaf', async () => { + const order: string[] = [] + + const middlewareA: FetchMiddleware = async (event, resolve) => { + order.push('a-before') + const response = await resolve(event) + order.push('a-after') + return response + } + + const middlewareB: FetchMiddleware = async (event, resolve) => { + order.push('b-before') + const response = await resolve(event) + order.push('b-after') + return response + } + + const composed = sequence(middlewareA, middlewareB) + const fetchEvent = createEvent() + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(composed, fetchEvent, async () => { + order.push('leaf') + return new Response('leaf-ok') + }) + }) + + expect(order).toEqual(['a-before', 'b-before', 'leaf', 'b-after', 'a-after']) + expect(await response.text()).toBe('leaf-ok') + }) + + test('3-arg fetch(request, env, ctx) handler is routed worker-style', async () => { + let seen: { request: Request, env: unknown, ctx: unknown } | null = null + + const handler = (request: Request, env: unknown, ctx: unknown) => { + seen = { request, env, ctx } + return new Response('worker-3') + } + + const fetchEvent = createEvent('https://example.com/three') + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(handler, fetchEvent) + }) + + expect(await response.text()).toBe('worker-3') + expect(seen).not.toBeNull() + expect(seen!.request).toBe(fetchEvent.request) + expect(seen!.env).toBe(fetchEvent.env) + expect(seen!.ctx).toBe(fetchEvent.ctx) + }) + + test('2-arg unmarked handler throws under R1-strict (no parameter-name fallback)', async () => { + const handler = simulateMinification(((_a: any, _b: any) => new Response('unreachable')) as (a: any, b: any) => Response) + + const fetchEvent = createEvent('https://example.com/two') + + await expect( + runWithEventContext(fetchEvent, async () => invokeFetchHandler(handler, fetchEvent)) + ).rejects.toThrow(/Ambiguous 2-argument fetch handler/) + }) + + test('2-arg handler marked worker-style is routed worker-style even when minified', async () => { + let seen: { a: unknown, b: unknown } | null = null + + const handler = simulateMinification(defineFetchHandler(((a: any, b: any) => { + seen = { a, b } + return new Response('worker-2') + }) as (a: any, b: any) => Response, { style: 'worker' })) + + const fetchEvent = createEvent('https://example.com/two-marked') + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(handler, fetchEvent) + }) + + expect(await response.text()).toBe('worker-2') + expect(seen).not.toBeNull() + expect(seen!.a).toBe(fetchEvent.request) + expect(seen!.b).toBe(fetchEvent.env) + }) + + test('2-arg handler marked via defineFetchHandler is routed resolve-style under minification', async () => { + let called = false + + const raw = (event: any, resolve: any) => { + called = true + return resolve(event) + } + const handler = simulateMinification(defineFetchHandler(raw, { style: 'resolve' })) + + const fetchEvent = createEvent('https://example.com/marked') + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(handler, fetchEvent, async () => new Response('from-resolve')) + }) + + expect(called).toBe(true) + expect(await response.text()).toBe('from-resolve') + }) + + test('0-arg and 1-arg unmarked handlers are NOT routed worker-style', async () => { + const zeroArg = (() => new Response('zero')) as () => Response + const oneArg = ((event: any) => { + // Should receive the FetchEvent, NOT Request/env/ctx spread + expect(event).toBeDefined() + expect(event.request).toBeInstanceOf(Request) + return new Response('one') + }) as (event: any) => Response + + const fetchEvent = createEvent('https://example.com/low-arity') + + const zeroResponse = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(zeroArg, fetchEvent) + }) + const oneResponse = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(oneArg, fetchEvent) + }) + + expect(await zeroResponse.text()).toBe('zero') + expect(await oneResponse.text()).toBe('one') + }) +}) diff --git a/packages/devflare/tests/unit/runtime/middleware.test.ts b/packages/devflare/tests/unit/runtime/middleware.test.ts new file mode 100644 index 0000000..271740c --- /dev/null +++ b/packages/devflare/tests/unit/runtime/middleware.test.ts @@ -0,0 +1,677 @@ +// ============================================================================= +// Middleware System Tests โ€” sequence() and fetch module dispatch +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + assertExplicitQueueHandlerStyle, + assertExplicitScheduledHandlerStyle, + createResolveFetch, + defineFetchHandler, + defineQueueHandler, + defineScheduledHandler, + invokeFetchHandler, + invokeFetchModule, + markResolveStyle, + markWorkerStyle, + resolveFetchHandler, + sequence, + type FetchMiddleware, + type ResolveFetch +} from '../../../src/runtime/middleware' +import { + createFetchEvent, + runWithEventContext, + type FetchEvent +} from '../../../src/runtime/context' + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } as ExecutionContext +} + +describe('sequence()', () => { + test('executes middlewares in order', async () => { + const order: string[] = [] + + const middleware1: FetchMiddleware = async (event, resolve) => { + order.push('m1-before') + const response = await resolve(event) + order.push('m1-after') + return response + } + + const middleware2: FetchMiddleware = async (event, resolve) => { + order.push('m2-before') + const response = await resolve(event) + order.push('m2-after') + return response + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/items'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return sequence(middleware1, middleware2)(fetchEvent, async () => { + order.push('leaf') + return new Response('OK') + }) + }) + + expect(order).toEqual(['m1-before', 'm2-before', 'leaf', 'm2-after', 'm1-after']) + expect(await response.text()).toBe('OK') + }) + + test('passes through when no middleware is configured', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/direct'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return sequence()(fetchEvent, async () => new Response('Direct')) + }) + + expect(await response.text()).toBe('Direct') + }) + + test('can short-circuit the chain', async () => { + const order: string[] = [] + + const authMiddleware: FetchMiddleware = async () => { + order.push('auth') + return new Response('Unauthorized', { status: 401 }) + } + + const skippedMiddleware: FetchMiddleware = async (event, resolve) => { + order.push('skipped') + return resolve(event) + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/secure'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return sequence(authMiddleware, skippedMiddleware)(fetchEvent, async () => { + order.push('leaf') + return new Response('OK') + }) + }) + + expect(order).toEqual(['auth']) + expect(response.status).toBe(401) + }) + + test('can modify the response on the way out', async () => { + const addHeader: FetchMiddleware = async (event, resolve) => { + const response = await resolve(event) + const wrapped = new Response(response.body, response) + wrapped.headers.set('X-Custom', 'added') + return wrapped + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/body'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return sequence(addHeader)(fetchEvent, async () => new Response('Body')) + }) + + expect(response.headers.get('X-Custom')).toBe('added') + }) + + test('propagates errors', async () => { + const throwingMiddleware: FetchMiddleware = async () => { + throw new Error('Middleware error') + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/error'), + {}, + createMockCtx() + ) + + await expect( + runWithEventContext(fetchEvent, async () => { + return sequence(throwingMiddleware)(fetchEvent, async () => new Response('OK')) + }) + ).rejects.toThrow('Middleware error') + }) +}) + +describe('resolveFetchHandler()', () => { + test('returns null when the module only exports method handlers', () => { + expect( + resolveFetchHandler({ + async GET() { + return new Response('ok') + } + }) + ).toBeNull() + }) + + test('returns the primary fetch entry when one is present', () => { + const fetch = async () => new Response('ok') + expect(resolveFetchHandler({ fetch })).toBe(fetch) + }) +}) + +describe('invokeFetchHandler()', () => { + test('supports resolve-style handlers', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/resolve-style'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler( + defineFetchHandler( + async (event: any, resolve: any) => { + const downstream = await resolve(event) + return new Response(`wrapped:${await downstream.text()}`) + }, + { style: 'resolve' } + ), + fetchEvent, + async () => new Response('ok') + ) + }) + + expect(await response.text()).toBe('wrapped:ok') + }) + + test('invokes event handlers without a resolve callback', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/items/123'), + {}, + createMockCtx(), + { params: { id: '123' } } + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(async (event: any) => { + return new Response(event.params.id) + }, fetchEvent) + }) + + expect(await response.text()).toBe('123') + }) + + test('invokes worker-style request/env/ctx handlers', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/worker-style', { method: 'POST' }), + { message: 'ok' }, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchHandler(async (request: any, env: any, ctx: any) => { + return new Response(`${request.method}:${env.message}:${typeof ctx.waitUntil}`) + }, fetchEvent) + }) + + expect(await response.text()).toBe('POST:ok:function') + }) +}) + +describe('createResolveFetch()', () => { + test('dispatches to matching method exports', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/users', { method: 'GET' }), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + const resolve = createResolveFetch( + { + async GET() { + return new Response('method-response') + } + }, + null, + fetchEvent + ) + + return resolve(fetchEvent) + }) + + expect(await response.text()).toBe('method-response') + }) + + test('reuses GET for HEAD without a response body', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/head', { method: 'HEAD' }), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + const resolve = createResolveFetch( + { + async GET() { + return new Response('body') + } + }, + null, + fetchEvent + ) + + return resolve(fetchEvent) + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + }) + + test('passes route params via event.params for 1-arg method handlers', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/users/42', { method: 'GET' }), + {}, + createMockCtx(), + { params: { id: '42' } } + ) + + const response = await runWithEventContext(fetchEvent, async () => { + const resolve = createResolveFetch( + { + async GET(event: FetchEvent & { params: { id: string } }) { + return new Response(event.params.id) + } + }, + null, + fetchEvent + ) + + return resolve(fetchEvent) + }) + + expect(await response.text()).toBe('42') + }) + + test('supports worker-style method handlers with request/env/ctx', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/worker-style', { method: 'GET' }), + { message: 'ok' }, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + const resolve = createResolveFetch( + { + async GET(request: any, env: any, ctx: any) { + return new Response(`${request.method}:${env.message}:${typeof ctx.waitUntil}`) + } + }, + null, + fetchEvent + ) + + return resolve(fetchEvent) + }) + + expect(await response.text()).toBe('GET:ok:function') + }) +}) + +describe('invokeFetchModule()', () => { + test('rejects modules that export both named handle and named fetch', async () => { + const fetchEvent = createFetchEvent(new Request('https://example.com/api'), {}, createMockCtx()) + + await expect( + runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + handle: sequence(async (event, resolve) => resolve(event)), + async fetch() { + return new Response('fetch-response') + } + }, + fetchEvent + ) + }) + ).rejects.toThrow('Export exactly one primary fetch entry per module') + }) + + test('rejects default export objects that expose both handle and fetch', async () => { + const fetchEvent = createFetchEvent(new Request('https://example.com/api'), {}, createMockCtx()) + + await expect( + runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + default: { + handle: sequence(async (event, resolve) => resolve(event)), + async fetch() { + return new Response('fetch-response') + } + } + }, + fetchEvent + ) + }) + ).rejects.toThrow('Export exactly one primary fetch entry per module') + }) + + test('uses named handle to wrap HTTP method exports', async () => { + const order: string[] = [] + + const handle1: FetchMiddleware = async (event, resolve) => { + order.push('handle1-before') + const response = await resolve(event) + order.push('handle1-after') + return response + } + + const handle2: FetchMiddleware = async (event, resolve) => { + order.push('handle2-before') + const response = await resolve(event) + order.push('handle2-after') + return response + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/users', { method: 'GET' }), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + handle: sequence(handle1, handle2), + async GET() { + order.push('GET') + return new Response('method-response') + } + }, + fetchEvent + ) + }) + + expect(order).toEqual([ + 'handle1-before', + 'handle2-before', + 'GET', + 'handle2-after', + 'handle1-after' + ]) + expect(await response.text()).toBe('method-response') + }) + + test('supports a named fetch export as the primary module entry', async () => { + const order: string[] = [] + + const middleware: FetchMiddleware = async (event, resolve) => { + order.push('before') + const response = await resolve(event) + order.push('after') + return response + } + + const fetchEvent = createFetchEvent( + new Request('https://example.com/health', { method: 'GET' }), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + fetch: sequence(middleware, async () => { + order.push('fetch') + return new Response('ok') + }) + }, + fetchEvent + ) + }) + + expect(order).toEqual(['before', 'fetch', 'after']) + expect(await response.text()).toBe('ok') + }) + + test('supports a worker-style named fetch(request, env) export as the primary module entry', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/worker-style-module', { method: 'PATCH' }), + { message: 'ok' }, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + fetch: defineFetchHandler( + async (request: any, env: any) => { + return new Response(`${request.method}:${env.message}`) + }, + { style: 'worker' } + ) + }, + fetchEvent + ) + }) + + expect(await response.text()).toBe('PATCH:ok') + }) + + test('supports a default fetch(event) export', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/default-fetch'), + { message: 'ok' }, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchModule( + { + default: { + async fetch(event: typeof fetchEvent) { + return new Response(event.env.message) + } + } + }, + fetchEvent + ) + }) + + expect(await response.text()).toBe('ok') + }) + + test('returns 404 when the module exposes no primary entry or method handler', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/missing'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(fetchEvent, async () => { + return invokeFetchModule({}, fetchEvent) + }) + + expect(response.status).toBe(404) + expect(await response.text()).toBe('Not Found') + }) +}) + +describe('R1-strict: 2-arg fetch handlers require explicit style', () => { + test('throws when an unmarked 2-arg handler is invoked via invokeFetchHandler', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/ambiguous'), + {}, + createMockCtx() + ) + + const handler = async (event: FetchEvent, resolve: ResolveFetch) => resolve(event) + + await expect( + runWithEventContext(fetchEvent, async () => + invokeFetchHandler(handler, fetchEvent, async () => new Response('ok')) + ) + ).rejects.toThrow(/Ambiguous 2-argument fetch handler/) + }) + + test('throws when an unmarked 2-arg method handler is dispatched via createResolveFetch', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/api/items/9', { method: 'GET' }), + {}, + createMockCtx(), + { params: { id: '9' } } + ) + + const moduleHandlers = { + async GET(_event: any, _params: { id: string }) { + return new Response('unreachable') + } + } + + await expect( + runWithEventContext(fetchEvent, async () => { + const resolve = createResolveFetch(moduleHandlers, null, fetchEvent) + return resolve(fetchEvent) + }) + ).rejects.toThrow(/Ambiguous 2-argument fetch handler/) + }) + + test('accepts a marked resolve-style 2-arg handler', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/marked-resolve'), + {}, + createMockCtx() + ) + + const handler = defineFetchHandler( + async (event: FetchEvent, resolve: ResolveFetch) => resolve(event), + { style: 'resolve' } + ) + + const response = await runWithEventContext(fetchEvent, async () => + invokeFetchHandler(handler, fetchEvent, async () => new Response('ok')) + ) + + expect(await response.text()).toBe('ok') + }) + + test('accepts a marked worker-style 2-arg handler', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/marked-worker', { method: 'POST' }), + { message: 'hi' }, + createMockCtx() + ) + + const handler = defineFetchHandler( + async (request: any, env: any) => new Response(`${request.method}:${env.message}`), + { style: 'worker' } + ) + + const response = await runWithEventContext(fetchEvent, async () => + invokeFetchHandler(handler, fetchEvent, async () => new Response('fallback')) + ) + + expect(await response.text()).toBe('POST:hi') + }) + + test('markWorkerStyle alone is sufficient for 2-arg worker handlers', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/marked-worker-bare'), + { id: 1 }, + createMockCtx() + ) + + const handler = markWorkerStyle(async (_request: any, env: any) => new Response(String(env.id))) + + const response = await runWithEventContext(fetchEvent, async () => + invokeFetchHandler(handler, fetchEvent, async () => new Response('fallback')) + ) + + expect(await response.text()).toBe('1') + }) + + test('markResolveStyle alone is sufficient for 2-arg resolve handlers', async () => { + const fetchEvent = createFetchEvent( + new Request('https://example.com/marked-resolve-bare'), + {}, + createMockCtx() + ) + + const handler = markResolveStyle(async (event: FetchEvent, resolve: ResolveFetch) => + resolve(event) + ) + + const response = await runWithEventContext(fetchEvent, async () => + invokeFetchHandler(handler, fetchEvent, async () => new Response('inner')) + ) + + expect(await response.text()).toBe('inner') + }) +}) + +describe('R1-strict: 2-arg queue handlers require explicit style', () => { + test('throws when an unmarked 2-arg queue handler is asserted', () => { + const handler = async (_batch: unknown, _env: unknown) => { } + + expect(() => assertExplicitQueueHandlerStyle(handler)).toThrow( + /Ambiguous 2-argument queue handler/ + ) + }) + + test('accepts a 1-arg queue handler', () => { + const handler = async (_event: unknown) => { } + + expect(() => assertExplicitQueueHandlerStyle(handler)).not.toThrow() + }) + + test('accepts a 3-arg queue handler', () => { + const handler = async (_batch: unknown, _env: unknown, _ctx: unknown) => { } + + expect(() => assertExplicitQueueHandlerStyle(handler)).not.toThrow() + }) + + test('accepts a marked 2-arg queue handler via defineQueueHandler', () => { + const handler = defineQueueHandler(async (_batch: unknown, _env: unknown) => { }) + + expect(() => assertExplicitQueueHandlerStyle(handler)).not.toThrow() + }) +}) + +describe('R1-strict: 2-arg scheduled handlers require explicit style', () => { + test('throws when an unmarked 2-arg scheduled handler is asserted', () => { + const handler = async (_controller: unknown, _env: unknown) => { } + + expect(() => assertExplicitScheduledHandlerStyle(handler)).toThrow( + /Ambiguous 2-argument scheduled handler/ + ) + }) + + test('accepts a 1-arg scheduled handler', () => { + const handler = async (_event: unknown) => { } + + expect(() => assertExplicitScheduledHandlerStyle(handler)).not.toThrow() + }) + + test('accepts a 3-arg scheduled handler', () => { + const handler = async (_controller: unknown, _env: unknown, _ctx: unknown) => { } + + expect(() => assertExplicitScheduledHandlerStyle(handler)).not.toThrow() + }) + + test('accepts a marked 2-arg scheduled handler via defineScheduledHandler', () => { + const handler = defineScheduledHandler(async (_controller: unknown, _env: unknown) => { }) + + expect(() => assertExplicitScheduledHandlerStyle(handler)).not.toThrow() + }) +}) diff --git a/packages/devflare/tests/unit/runtime/router.test.ts b/packages/devflare/tests/unit/runtime/router.test.ts new file mode 100644 index 0000000..b026f76 --- /dev/null +++ b/packages/devflare/tests/unit/runtime/router.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test' +import { createFetchEvent, runWithEventContext } from '../../../src/runtime/context' +import { invokeFetchModule, sequence, type FetchMiddleware } from '../../../src/runtime/middleware' +import { createRouteResolve, invokeRouteModules, matchFetchRoute } from '../../../src/runtime/router' +import type { RouteModuleDefinition } from '../../../src/runtime/router/types' + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } as ExecutionContext +} + +describe('runtime file router', () => { + test('matches static, dynamic, and rest routes in order of specificity', () => { + const routes: RouteModuleDefinition[] = [ + { + filePath: 'src/routes/users/settings.ts', + routePath: '/users/settings', + segments: [ + { type: 'static', value: 'users' }, + { type: 'static', value: 'settings' } + ], + module: {} + }, + { + filePath: 'src/routes/users/[id].ts', + routePath: '/users/[id]', + segments: [ + { type: 'static', value: 'users' }, + { type: 'param', name: 'id' } + ], + module: {} + }, + { + filePath: 'src/routes/users/[...slug].ts', + routePath: '/users/[...slug]', + segments: [ + { type: 'static', value: 'users' }, + { type: 'rest', name: 'slug' } + ], + module: {} + } + ] + + expect(matchFetchRoute(routes, '/users/settings')?.route.routePath).toBe('/users/settings') + expect(matchFetchRoute(routes, '/users/42')?.params).toEqual({ id: '42' }) + expect(matchFetchRoute(routes, '/users/42/posts')?.params).toEqual({ slug: '42/posts' }) + }) + + test('invokes the matched route module with populated params', async () => { + const route: RouteModuleDefinition = { + filePath: 'src/routes/users/[id].ts', + routePath: '/users/[id]', + segments: [ + { type: 'static', value: 'users' }, + { type: 'param', name: 'id' } + ], + module: { + async GET(event: { params: { id: string } }) { + return new Response(`user:${event.params.id}`) + } + } + } + + const event = createFetchEvent( + new Request('https://example.com/users/42'), + {}, + createMockCtx() + ) + + const response = await runWithEventContext(event, () => invokeRouteModules([route], event)) + expect(await response.text()).toBe('user:42') + }) + + test('lets request-wide fetch middleware wrap matched route modules and read params', async () => { + const route: RouteModuleDefinition = { + filePath: 'src/routes/users/[id].ts', + routePath: '/users/[id]', + segments: [ + { type: 'static', value: 'users' }, + { type: 'param', name: 'id' } + ], + module: { + async GET(event: { params: { id: string } }) { + return new Response(event.params.id) + } + } + } + + const request = new Request('https://example.com/users/42') + const initialMatch = matchFetchRoute([route], request) + const event = createFetchEvent(request, {}, createMockCtx(), { + params: initialMatch?.params ?? {} + }) + + const middleware: FetchMiddleware = async (activeEvent, resolve) => { + expect(activeEvent.params.id).toBe('42') + const response = await resolve(activeEvent) + const next = new Response(response.body, response) + next.headers.set('x-route-id', activeEvent.params.id) + return next + } + + const response = await runWithEventContext(event, () => invokeFetchModule( + { + handle: sequence(middleware) + }, + event, + createRouteResolve([route], event) + )) + + expect(await response.text()).toBe('42') + expect(response.headers.get('x-route-id')).toBe('42') + }) +}) diff --git a/packages/devflare/tests/unit/runtime/send-email-env-wrapper.test.ts b/packages/devflare/tests/unit/runtime/send-email-env-wrapper.test.ts new file mode 100644 index 0000000..570732b --- /dev/null +++ b/packages/devflare/tests/unit/runtime/send-email-env-wrapper.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + clearLocalSendEmailBindings, + setLocalSendEmailBindings, + wrapEnvSendEmailBindings +} from '../../../src/utils/send-email' + +describe('wrapEnvSendEmailBindings', () => { + afterEach(() => { + clearLocalSendEmailBindings() + }) + + test('does not probe non-email RPC-style bindings unsafely', () => { + const serviceBinding = { + get send(): never { + throw new Error('RPC receiver does not implement the method "send".') + } + } + + const env = { SERVICE: serviceBinding } + + expect(wrapEnvSendEmailBindings(env)).toBe(env) + }) + + test('prefers configured local sendEmail bindings over runtime sendEmail bindings', async () => { + let runtimeSendCalls = 0 + const runtimeSendEmail = { + async send(): Promise { + runtimeSendCalls += 1 + throw new Error('runtime sendEmail should not be called') + } + } as SendEmail + + setLocalSendEmailBindings({ + EMAIL: { + destinationAddress: 'recipient@example.com', + allowedSenderAddresses: ['sender@example.com'] + } + }) + + const env = wrapEnvSendEmailBindings<{ EMAIL: SendEmail }>({ + EMAIL: runtimeSendEmail + }) + + await env.EMAIL.send({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Hello', + text: 'Sent locally' + }) + + expect(runtimeSendCalls).toBe(0) + expect(Object.getOwnPropertyDescriptor(env, 'EMAIL')?.value).not.toBe(runtimeSendEmail) + }) +}) diff --git a/packages/devflare/tests/unit/runtime/validation.test.ts b/packages/devflare/tests/unit/runtime/validation.test.ts new file mode 100644 index 0000000..fffd45a --- /dev/null +++ b/packages/devflare/tests/unit/runtime/validation.test.ts @@ -0,0 +1,122 @@ +// ============================================================================= +// Validation Proxy Tests โ€” Runtime safety for context access +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { createContextProxy, ContextAccessError } from '../../../src/runtime/validation' +import { runWithContext } from '../../../src/runtime/context' + +describe('createContextProxy', () => { + test('allows access when context is available', () => { + let envValue: { DB: string } | undefined + + const envProxy = createContextProxy(() => envValue, 'env') + + envValue = { DB: 'database' } + + // This should throw because we're not in context + // even though envValue is defined - the getter returns undefined outside context + }) + + test('throws ContextAccessError when getter returns undefined', () => { + const proxy = createContextProxy<{ value: string }>(() => undefined, 'env') + + expect(() => proxy.value).toThrow(ContextAccessError) + }) + + test('error includes property name', () => { + const proxy = createContextProxy<{ DB: string }>(() => undefined, 'env') + + try { + const _ = proxy.DB + expect(true).toBe(false) // Should not reach + } catch (e) { + expect(e).toBeInstanceOf(ContextAccessError) + const error = e as ContextAccessError + expect(error.message).toContain('env.DB') + } + }) + + test('error includes helpful guidance', () => { + const proxy = createContextProxy<{ value: string }>(() => undefined, 'locals') + + try { + const _ = proxy.value + } catch (e) { + const error = e as ContextAccessError + expect(error.message).toContain('outside of an active Devflare handler trail') + expect(error.message).toContain('Move the access inside') + } + }) + + test('returns value when getter returns defined object', () => { + const mockEnv = { DB: 'my-database', KV: 'my-kv' } + const proxy = createContextProxy(() => mockEnv, 'env') + + expect(proxy.DB).toBe('my-database') + expect(proxy.KV).toBe('my-kv') + }) + + test('supports setting values', () => { + const mockLocals: Record = {} + const proxy = createContextProxy(() => mockLocals, 'locals') + + proxy.userId = '123' + expect(mockLocals.userId).toBe('123') + }) + + test('setting throws when context unavailable', () => { + const proxy = createContextProxy>(() => undefined, 'locals') + + expect(() => { + proxy.value = 'test' + }).toThrow(ContextAccessError) + }) + + test('has() returns false when context unavailable', () => { + const proxy = createContextProxy<{ value: string }>(() => undefined, 'env') + + expect('value' in proxy).toBe(false) + }) + + test('has() returns true when property exists in context', () => { + const mockEnv = { DB: 'database' } + const proxy = createContextProxy(() => mockEnv, 'env') + + expect('DB' in proxy).toBe(true) + expect('MISSING' in proxy).toBe(false) + }) + + test('ownKeys() returns empty array when context unavailable', () => { + const proxy = createContextProxy<{ value: string }>(() => undefined, 'env') + + expect(Object.keys(proxy)).toEqual([]) + }) + + test('ownKeys() returns actual keys when context available', () => { + const mockEnv = { DB: 'db', KV: 'kv' } + const proxy = createContextProxy(() => mockEnv, 'env') + + expect(Object.keys(proxy)).toEqual(['DB', 'KV']) + }) +}) + +describe('integration with runWithContext', () => { + test('proxy works correctly within context', () => { + const mockEnv = { API_KEY: 'secret' } + const mockCtx: ExecutionContext = { + waitUntil: () => { }, + passThroughOnException: () => { }, + props: {} + } + + let envValue: typeof mockEnv | undefined + const envProxy = createContextProxy(() => envValue, 'env') + + runWithContext(mockEnv, mockCtx, null, () => { + // Simulate how the real implementation would work + envValue = mockEnv + expect(envProxy.API_KEY).toBe('secret') + }) + }) +}) diff --git a/packages/devflare/tests/unit/secrets/local-secrets.test.ts b/packages/devflare/tests/unit/secrets/local-secrets.test.ts new file mode 100644 index 0000000..9a16904 --- /dev/null +++ b/packages/devflare/tests/unit/secrets/local-secrets.test.ts @@ -0,0 +1,192 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' + +import { + deleteLocalSecret, + listLocalSecrets, + buildLocalSecretWrappedBindingConfig, + readLocalSecret, + resolveLocalSecretValuesForBindings, + seedMiniflareLocalSecrets, + writeLocalSecret +} from '../../../src/secrets/local-secrets' +import type { DevflareConfig } from '../../../src/config' +import { startMiniflareFromConfig } from '../../../src/bridge/miniflare' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-local-secrets-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('local Secrets Store file', () => { + test('writes, reads, lists, and deletes local secrets by store id and name', () => { + const cwd = createTempDir() + + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + expect(readLocalSecret({ cwd, storeId: 'store-123', name: 'api-token' })).toBe('local-secret') + expect(listLocalSecrets({ cwd, storeId: 'store-123' })).toEqual([ + { + storeId: 'store-123', + name: 'api-token', + hasValue: true, + updatedAt: expect.any(String) + } + ]) + + expect(deleteLocalSecret({ cwd, storeId: 'store-123', name: 'api-token' })).toBe(true) + expect(readLocalSecret({ cwd, storeId: 'store-123', name: 'api-token' })).toBeUndefined() + }) + + test('resolves configured Secrets Store bindings from the local store without exposing values in config', () => { + const cwd = createTempDir() + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + writeLocalSecret({ cwd, storeId: 'store-admin', name: 'admin-token', value: 'admin-secret' }) + + const config = { + name: 'secret-worker', + compatibilityDate: '2026-04-27', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + ADMIN_TOKEN: { + storeId: 'store-admin', + secretName: 'admin-token' + } + } + } + } satisfies DevflareConfig + + expect(resolveLocalSecretValuesForBindings(config, cwd)).toEqual({ + API_TOKEN: 'local-secret', + ADMIN_TOKEN: 'admin-secret' + }) + }) + + test('seeds Miniflare Secrets Store admin APIs from local values', async () => { + const cwd = createTempDir() + const created: string[] = [] + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + const config = { + name: 'secret-worker', + compatibilityDate: '2026-04-27', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + } satisfies DevflareConfig + + await seedMiniflareLocalSecrets( + { + async getSecretsStoreSecretAPI(bindingName: string) { + expect(bindingName).toBe('API_TOKEN') + return { + async create(value: string) { + created.push(value) + return 'secret-id' + } + } + } + }, + config, + cwd + ) + + expect(created).toEqual(['local-secret']) + }) + + test('skips Miniflare Secrets Store seeding when the runtime has no admin API', async () => { + const cwd = createTempDir() + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + const config = { + name: 'secret-worker', + compatibilityDate: '2026-04-27', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + } satisfies DevflareConfig + + await expect(seedMiniflareLocalSecrets({}, config, cwd)).resolves.toBeUndefined() + }) + + test('builds wrapped binding workers for locally stored Secrets Store values', async () => { + const cwd = createTempDir() + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + const config = { + name: 'secret-worker', + compatibilityDate: '2026-04-27', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + REMOTE_ONLY: 'remote-only' + } + } + } satisfies DevflareConfig + + const wrapped = buildLocalSecretWrappedBindingConfig(config, cwd) + + expect(wrapped.localBindingNames).toEqual(['API_TOKEN']) + expect(wrapped.wrappedBindings.API_TOKEN).toEqual({ + scriptName: 'devflare-local-secret-0-api-token', + bindings: { + value: 'local-secret' + } + }) + expect(wrapped.workers).toHaveLength(1) + expect(wrapped.workers[0]).toMatchObject({ + name: 'devflare-local-secret-0-api-token', + modules: true + }) + expect(wrapped.workers[0]?.script).toContain('async get()') + }) + + test('seeds an actual Miniflare Secrets Store binding from local values', async () => { + const cwd = createTempDir() + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + const config = { + name: 'secret-worker', + compatibilityDate: '2026-04-27', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + } satisfies DevflareConfig + + const instance = await startMiniflareFromConfig(config, { cwd, port: 0 }) + try { + const bindings = await instance.getBindings() + expect(await (bindings.API_TOKEN as SecretsStoreSecret).get()).toBe('local-secret') + } finally { + await instance.dispose() + } + }) +}) diff --git a/packages/devflare/tests/unit/sveltekit/local-bindings.test.ts b/packages/devflare/tests/unit/sveltekit/local-bindings.test.ts new file mode 100644 index 0000000..0caaf95 --- /dev/null +++ b/packages/devflare/tests/unit/sveltekit/local-bindings.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test' +import { buildSvelteKitLocalBindings } from '../../../src/sveltekit/local-bindings' + +describe('buildSvelteKitLocalBindings', () => { + test('exposes config vars as normal synchronous platform.env strings', () => { + const bindings = buildSvelteKitLocalBindings({ + name: 'sveltekit-vars', + vars: { + API_ORIGIN: 'http://127.0.0.1:8791' + } + }, process.cwd()) + + expect(bindings.API_ORIGIN).toBe('http://127.0.0.1:8791') + expect(String(bindings.API_ORIGIN)).toBe('http://127.0.0.1:8791') + }) +}) diff --git a/packages/devflare/tests/unit/sveltekit/platform.test.ts b/packages/devflare/tests/unit/sveltekit/platform.test.ts new file mode 100644 index 0000000..78aec4f --- /dev/null +++ b/packages/devflare/tests/unit/sveltekit/platform.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { drainWaitUntilErrors, type Platform } from '../../../src/sveltekit/platform' + +function buildTestPlatform(): Platform { + const pendingErrors: unknown[] = [] + const context = { + waitUntil: (promise: Promise) => { + promise.catch((err) => { + pendingErrors.push(err) + }) + }, + passThroughOnException: () => { } + } as ExecutionContext + + return { + env: {}, + context, + caches: {} as CacheStorage, + cf: {}, + pendingErrors + } +} + +describe('sveltekit platform waitUntil error capture', () => { + test('captures errors thrown inside ctx.waitUntil and returns them from drainWaitUntilErrors', async () => { + const platform = buildTestPlatform() + const boom = new Error('waitUntil failure') + + platform.context.waitUntil(Promise.reject(boom)) + + // Allow the rejection handler to run + await new Promise((resolve) => setTimeout(resolve, 0)) + + const drained = drainWaitUntilErrors(platform) + expect(drained).toEqual([boom]) + + // Buffer is cleared after drain + expect(drainWaitUntilErrors(platform)).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/test/ai-search-mocks.test.ts b/packages/devflare/tests/unit/test/ai-search-mocks.test.ts new file mode 100644 index 0000000..6a95d53 --- /dev/null +++ b/packages/devflare/tests/unit/test/ai-search-mocks.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from 'bun:test' +import { createMockAISearchInstance, createMockAISearchNamespace } from '../../../src/test' + +describe('createMockAISearchInstance', () => { + test('searches seeded and uploaded items deterministically', async () => { + const instance = createMockAISearchInstance({ + id: 'docs', + items: [ + { + key: 'cache.md', + content: 'Cloudflare cache API stores responses', + metadata: { slug: '/cache' } + }, + { + key: 'queues.md', + content: 'Queues process asynchronous jobs' + } + ] + }) + + const initial = await instance.search({ query: 'cache' }) + expect(initial.search_query).toBe('cache') + expect(initial.chunks).toHaveLength(1) + expect(initial.chunks[0].text).toContain('cache API') + expect(initial.chunks[0].item.metadata).toEqual({ slug: '/cache' }) + + const uploaded = await instance.items.upload( + 'offline.md', + 'Offline fixtures avoid network access' + ) + expect(uploaded.status).toBe('completed') + + const afterUpload = await instance.search({ query: 'offline' }) + expect(afterUpload.chunks.map((chunk) => chunk.item.key)).toEqual(['offline.md']) + expect((await instance.items.list()).result.map((item) => item.key)).toEqual([ + 'cache.md', + 'queues.md', + 'offline.md' + ]) + }) + + test('supports item and job helper APIs without network access', async () => { + const instance = createMockAISearchInstance({ + id: 'docs', + items: [ + { + key: 'offline.md', + content: 'Offline testing support' + } + ] + }) + + const itemInfo = (await instance.items.list()).result[0] + const item = instance.items.get(itemInfo.id) + const download = await item.download() + expect(download.filename).toBe('offline.md') + expect(await new Response(download.body).text()).toBe('Offline testing support') + expect((await item.chunks()).result[0].text).toBe('Offline testing support') + + const job = await instance.jobs.create({ description: 'manual sync' }) + expect(job.source).toBe('user') + expect((await instance.jobs.list()).result).toEqual([job]) + expect((await instance.jobs.get(job.id).cancel()).end_reason).toBe('cancelled') + }) + + test('returns deterministic chat completions from matched chunks', async () => { + const instance = createMockAISearchInstance({ + id: 'docs', + items: [ + { + key: 'cache.md', + content: 'Cache API stores responses near users' + } + ] + }) + + const completion = await instance.chatCompletions({ + messages: [ + { + role: 'user', + content: 'Where are responses stored?' + } + ] + }) + + expect(completion.choices[0].message.content).toContain('Cache API stores responses') + expect(completion.chunks).toHaveLength(1) + }) +}) + +describe('createMockAISearchNamespace', () => { + test('creates, lists, gets, deletes, and multi-searches instances', async () => { + const namespace = createMockAISearchNamespace({ + instances: { + docs: { + items: [ + { + key: 'docs.md', + content: 'Documentation explains offline support' + } + ] + }, + blog: { + items: [ + { + key: 'blog.md', + content: 'Release notes explain remote boundaries' + } + ] + } + } + }) + + expect((await namespace.list()).result.map((instance) => instance.id)).toEqual(['docs', 'blog']) + + const created = await namespace.create({ id: 'guides' }) + await created.items.upload('guide.md', 'Guides cover fixtures') + expect((await namespace.get('guides').search({ query: 'fixtures' })).chunks).toHaveLength(1) + + const multi = await namespace.search({ + query: 'support', + ai_search_options: { + instance_ids: ['docs', 'blog', 'guides'] + } + }) + expect(multi.chunks.map((chunk) => chunk.instance_id)).toEqual(['docs']) + + await namespace.delete('blog') + expect((await namespace.list()).result.map((instance) => instance.id)).toEqual([ + 'docs', + 'guides' + ]) + }) +}) diff --git a/packages/devflare/tests/unit/test/containers.test.ts b/packages/devflare/tests/unit/test/containers.test.ts new file mode 100644 index 0000000..0fa1d96 --- /dev/null +++ b/packages/devflare/tests/unit/test/containers.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type ContainerCommandResult, + type ContainerCommandRunner, + createContainerManager, + detectContainerEngine, + getContainerSkipReason +} from '../../../src/test/containers' + +interface CommandCall { + command: string + args: string[] +} + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +function ok(stdout = ''): ContainerCommandResult { + return { + exitCode: 0, + stdout, + stderr: '' + } +} + +function fail(stderr = 'failed'): ContainerCommandResult { + return { + exitCode: 1, + stdout: '', + stderr + } +} + +function createRunner( + handler: ( + command: string, + args: string[] + ) => ContainerCommandResult | Promise +): { runner: ContainerCommandRunner; calls: CommandCall[] } { + const calls: CommandCall[] = [] + return { + calls, + runner: { + async exec(command, args) { + calls.push({ command, args }) + return await handler(command, args) + } + } + } +} + +function createContainerRunner() { + const dockerResponses = new Map ContainerCommandResult>([ + ['info', () => ok('Docker is running')], + ['image inspect', () => ok('[]')], + ['build', () => ok('built')], + ['run', () => ok('container-id')], + ['logs', () => ok('hello logs')], + [ + 'inspect', + () => + ok( + JSON.stringify({ + Status: 'running', + Running: true, + ExitCode: 0 + }) + ) + ], + ['stop', () => ok()], + ['rm', () => ok()] + ]) + + return createRunner((command, args) => { + const dockerCommand = args[0] === 'image' ? `${args[0]} ${args[1]}` : args[0] + return command === 'docker' + ? (dockerResponses.get(dockerCommand)?.() ?? fail(`${command} ${args.join(' ')}`)) + : fail(`${command} ${args.join(' ')}`) + }) +} + +describe('detectContainerEngine', () => { + test('detects Docker when the CLI and engine are reachable', async () => { + const { runner, calls } = createRunner((command, args) => { + if (command === 'docker' && args[0] === 'info') { + return ok('Docker is running') + } + return fail('unexpected command') + }) + + const status = await detectContainerEngine({ runner }) + + expect(status.available).toBe(true) + if (!status.available) { + throw new Error(status.reason) + } + expect(status.engine).toBe('docker') + expect(calls).toEqual([{ command: 'docker', args: ['info'] }]) + }) + + test('falls back to Podman when Docker is not reachable', async () => { + const { runner, calls } = createRunner((command, args) => { + if (command === 'docker' && args[0] === 'info') { + return fail('Cannot connect to the Docker daemon') + } + if (command === 'podman' && args[0] === 'info') { + return ok('Podman is running') + } + return fail('unexpected command') + }) + + const status = await detectContainerEngine({ runner }) + + expect(status.available).toBe(true) + if (!status.available) { + throw new Error(status.reason) + } + expect(status.engine).toBe('podman') + expect(calls).toEqual([ + { command: 'docker', args: ['info'] }, + { command: 'podman', args: ['info'] } + ]) + }) +}) + +describe('getContainerSkipReason', () => { + test('skips unless real container tests are explicitly enabled', async () => { + const { runner, calls } = createContainerRunner() + + const reason = await getContainerSkipReason({ env: {}, runner }) + + expect(reason).toContain('DEVFLARE_CONTAINER_TESTS=1') + expect(calls).toEqual([]) + }) + + test('does not skip when tests are enabled and an engine is reachable', async () => { + const { runner } = createContainerRunner() + + const reason = await getContainerSkipReason({ + env: { DEVFLARE_CONTAINER_TESTS: '1' }, + runner + }) + + expect(reason).toBeNull() + }) +}) + +describe('devflare/test public surface', () => { + test('exports container helpers and the containers skip getter', async () => { + const testApi = await import('../../../src/test') + + expect(testApi.containers).toBeDefined() + expect(typeof testApi.createContainerManager).toBe('function') + expect(typeof testApi.detectContainerEngine).toBe('function') + expect('containers' in testApi.shouldSkip).toBe(true) + }) +}) + +describe('createContainerManager', () => { + test('starts a pre-existing local image offline and exposes fetch/log/state/stop APIs', async () => { + const { runner, calls } = createContainerRunner() + const fetched: Request[] = [] + const manager = createContainerManager({ + runner, + cwd: 'C:/project', + allocatePort: async () => 49152, + waitForPort: async () => {}, + fetch: (async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + fetched.push(request) + return new Response(request.url) + }) as typeof fetch + }) + + const container = await manager.start('MyContainer', { + image: 'ghcr.io/acme/app:local', + port: 8080, + instance: 'case-1', + envVars: { + TOKEN: 'offline' + } + }) + + const runCall = calls.find((call) => call.args[0] === 'run') + expect( + calls.some((call) => call.args.join(' ') === 'image inspect ghcr.io/acme/app:local') + ).toBe(true) + expect(calls.some((call) => call.args[0] === 'pull')).toBe(false) + expect(runCall?.args).toContain('-d') + expect(runCall?.args).toContain('127.0.0.1:49152:8080') + expect(runCall?.args).toContain('TOKEN=offline') + expect(runCall?.args.at(-1)).toBe('ghcr.io/acme/app:local') + + const response = await container.fetch('/health') + await container.fetch('https://example.com/status?probe=1') + await container.fetch(new URL('https://example.com/deep/path?ok=1')) + const logs = await container.logs() + const state = await container.getState() + await container.stop() + + expect(await response.text()).toBe('http://127.0.0.1:49152/health') + expect(fetched[0].url).toBe('http://127.0.0.1:49152/health') + expect(fetched[1].url).toBe('http://127.0.0.1:49152/status?probe=1') + expect(fetched[2].url).toBe('http://127.0.0.1:49152/deep/path?ok=1') + expect(logs).toBe('hello logs') + expect(state).toEqual({ + status: 'running', + running: true, + exitCode: 0 + }) + expect(calls.some((call) => call.args[0] === 'stop' && call.args[1] === container.name)).toBe( + true + ) + expect( + calls.some( + (call) => call.args[0] === 'rm' && call.args[1] === '-f' && call.args[2] === container.name + ) + ).toBe(true) + }) + + test('fails offline image-reference starts when the image is not present locally', async () => { + const { runner, calls } = createRunner((command, args) => { + if (command === 'docker' && args[0] === 'info') { + return ok() + } + if (command === 'docker' && args[0] === 'image' && args[1] === 'inspect') { + return fail('No such image') + } + return fail('unexpected command') + }) + const manager = createContainerManager({ + runner, + cwd: 'C:/project', + allocatePort: async () => 49152, + waitForPort: async () => {} + }) + + await expect( + manager.start('MyContainer', { + image: 'ghcr.io/acme/app:missing', + port: 8080 + }) + ).rejects.toThrow('is not present locally') + + expect(calls.some((call) => call.args[0] === 'pull')).toBe(false) + }) + + test('removes the container when readiness fails after run succeeds', async () => { + const { runner, calls } = createContainerRunner() + const manager = createContainerManager({ + runner, + cwd: 'C:/project', + allocatePort: async () => 49152, + waitForPort: async () => { + throw new Error('not ready') + } + }) + + await expect( + manager.start('MyContainer', { + image: 'ghcr.io/acme/app:local', + port: 8080 + }) + ).rejects.toThrow('not ready') + + const runCall = calls.find((call) => call.args[0] === 'run') + const nameIndex = runCall?.args.indexOf('--name') ?? -1 + const containerName = nameIndex >= 0 ? runCall?.args[nameIndex + 1] : undefined + + expect(containerName).toBeDefined() + expect( + calls.some( + (call) => call.args[0] === 'rm' && call.args[1] === '-f' && call.args[2] === containerName + ) + ).toBe(true) + }) + + test('builds local Dockerfiles offline without pulling newer base layers', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'devflare-container-')) + tempDirs.push(tempDir) + const dockerfile = join(tempDir, 'Dockerfile') + await writeFile(dockerfile, 'FROM scratch\n') + const { runner, calls } = createContainerRunner() + const manager = createContainerManager({ + runner, + cwd: tempDir, + allocatePort: async () => 49152, + waitForPort: async () => {} + }) + + await manager.start('MyContainer', { + image: './Dockerfile', + port: 8080 + }) + + const buildCall = calls.find((call) => call.args[0] === 'build') + expect(buildCall?.args).toContain('--pull=false') + expect(buildCall?.args).toContain('-f') + expect(buildCall?.args).toContain(dockerfile) + expect(buildCall?.args).toContain(tempDir) + }) +}) diff --git a/packages/devflare/tests/unit/test/mock-kv.test.ts b/packages/devflare/tests/unit/test/mock-kv.test.ts new file mode 100644 index 0000000..dc19f89 --- /dev/null +++ b/packages/devflare/tests/unit/test/mock-kv.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'bun:test' +import { createMockKV } from '../../../src/test/utilities' + +const BINARY = new Uint8Array([0xff, 0xfe, 0xfd, 0x00, 0xaa]) + +const expectBytesEqual = (actual: Uint8Array, expected: Uint8Array) => { + expect(actual.length).toBe(expected.length) + for (let i = 0;i < expected.length;i++) { + expect(actual[i]).toBe(expected[i]) + } +} + +const readAll = async (stream: ReadableStream): Promise => { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const result = await reader.read() + if (result.done) break + if (result.value) { + chunks.push(result.value) + total += result.value.length + } + } + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +describe('createMockKV', () => { + test('round-trips non-UTF-8 ArrayBuffer via put(ArrayBuffer) + get(arrayBuffer)', async () => { + const kv = createMockKV() + const input = new Uint8Array(BINARY) + await kv.put('bin', input.buffer) + + const out = (await kv.get('bin', 'arrayBuffer')) as ArrayBuffer + expect(out).toBeInstanceOf(ArrayBuffer) + expectBytesEqual(new Uint8Array(out), BINARY) + }) + + test('arrayBuffer result is independent of stored bytes', async () => { + const kv = createMockKV() + await kv.put('bin', new Uint8Array(BINARY).buffer) + + const first = (await kv.get('bin', 'arrayBuffer')) as ArrayBuffer + new Uint8Array(first).fill(0) + + const second = (await kv.get('bin', 'arrayBuffer')) as ArrayBuffer + expectBytesEqual(new Uint8Array(second), BINARY) + }) + + test('round-trips bytes put via multi-chunk ReadableStream', async () => { + const kv = createMockKV() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xff, 0xfe])) + controller.enqueue(new Uint8Array([0xfd])) + controller.enqueue(new Uint8Array([0x00, 0xaa])) + controller.close() + } + }) + + await kv.put('bin', stream) + + const out = (await kv.get('bin', 'arrayBuffer')) as ArrayBuffer + expectBytesEqual(new Uint8Array(out), BINARY) + }) + + test('get(type: stream) emits bytes equal to stored payload', async () => { + const kv = createMockKV() + await kv.put('bin', new Uint8Array(BINARY).buffer) + + const stream = (await kv.get('bin', 'stream')) as ReadableStream + expect(stream).toBeInstanceOf(ReadableStream) + const collected = await readAll(stream) + expectBytesEqual(collected, BINARY) + }) + + test('default text put/get still works', async () => { + const kv = createMockKV() + await kv.put('greeting', 'hello world') + + expect(await kv.get('greeting')).toBe('hello world') + expect(await kv.get('greeting', 'text')).toBe('hello world') + }) + + test('get(type: json) parses JSON strings', async () => { + const kv = createMockKV() + const payload = { a: 1, b: [true, 'x'] } + await kv.put('obj', JSON.stringify(payload)) + + const parsed = (await kv.get('obj', 'json')) as typeof payload + expect(parsed).toEqual(payload) + }) + + test('list returns stored key names', async () => { + const kv = createMockKV({ alpha: '1' }) + await kv.put('beta', 'two') + await kv.put('gamma', new Uint8Array([0xff]).buffer) + + const result = await kv.list() + const names = result.keys.map((k) => k.name).sort() + expect(names).toEqual(['alpha', 'beta', 'gamma']) + }) + + test('get returns null for missing key', async () => { + const kv = createMockKV() + expect(await kv.get('missing')).toBeNull() + }) + + test('delete removes entries', async () => { + const kv = createMockKV({ a: '1' }) + await kv.delete('a') + expect(await kv.get('a')).toBeNull() + const result = await kv.list() + expect(result.keys).toEqual([]) + }) + + test('getWithMetadata returns binary-safe ArrayBuffer when requested', async () => { + const kv = createMockKV() + await kv.put('bin', new Uint8Array(BINARY).buffer) + + const result = await kv.getWithMetadata('bin', { type: 'arrayBuffer' }) + expect(result.value).toBeInstanceOf(ArrayBuffer) + expectBytesEqual(new Uint8Array(result.value as ArrayBuffer), BINARY) + }) + + test('getWithMetadata returns ReadableStream when type is stream', async () => { + const kv = createMockKV() + await kv.put('bin', new Uint8Array(BINARY).buffer) + + const result = await kv.getWithMetadata('bin', { type: 'stream' }) + expect(result.value).toBeInstanceOf(ReadableStream) + const collected = await readAll(result.value as ReadableStream) + expectBytesEqual(collected, BINARY) + }) + + test('getWithMetadata parses JSON when type is json', async () => { + const kv = createMockKV() + const payload = { ok: true, n: 42 } + await kv.put('obj', JSON.stringify(payload)) + + const result = await kv.getWithMetadata('obj', { type: 'json' }) + expect(result.value).toEqual(payload) + }) +}) diff --git a/packages/devflare/tests/unit/test/offline-bindings.test.ts b/packages/devflare/tests/unit/test/offline-bindings.test.ts new file mode 100644 index 0000000..1a68efa --- /dev/null +++ b/packages/devflare/tests/unit/test/offline-bindings.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import type { Pipeline } from 'cloudflare:pipelines' +import { writeLocalSecret } from '../../../src/secrets/local-secrets' +import { + createOfflineBindings, + createOfflineEnv, + describeOfflineSupport, + getOfflineSupportMatrix +} from '../../../src/test' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-offline-secrets-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('offline support matrix', () => { + test('classifies services by honest offline support tier', () => { + const matrix = getOfflineSupportMatrix() + + expect(matrix.containers.tier).toBe('offline-native') + expect(matrix.hyperdrive.tier).toBe('offline-native') + expect(matrix.workerLoaders.tier).toBe('offline-native') + expect(matrix.workflows.tier).toBe('offline-native') + expect(matrix.aiSearch.tier).toBe('offline-fixture') + expect(matrix.media.tier).toBe('offline-native') + expect(matrix.mtlsCertificates.tier).toBe('offline-fixture') + expect(matrix.ai.tier).toBe('remote-boundary') + expect(matrix.vectorize.tier).toBe('remote-boundary') + expect(matrix.builds.tier).toBe('remote-boundary') + }) + + test('describes unknown services as remote-boundary instead of guessing', () => { + const support = describeOfflineSupport('future-cloudflare-product') + + expect(support.tier).toBe('remote-boundary') + expect(support.reason).toContain('No offline support classification') + expect(support.recommendation).toContain('remote') + }) + + test('exports skip getters for documented remote-boundary integration tests', async () => { + const testApi = await import('../../../src/test') + + expect('aiSearch' in testApi.shouldSkip).toBe(true) + expect('aiGateway' in testApi.shouldSkip).toBe(true) + expect('media' in testApi.shouldSkip).toBe(true) + expect('mtlsCertificates' in testApi.shouldSkip).toBe(true) + expect('artifacts' in testApi.shouldSkip).toBe(true) + expect('builds' in testApi.shouldSkip).toBe(true) + }) +}) + +describe('createOfflineBindings', () => { + const config = { + name: 'offline-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + vars: { + PUBLIC_VALUE: 'local' + }, + bindings: { + rateLimits: { + RATE_LIMITER: { + namespaceId: '1001', + simple: { + limit: 1, + period: 10 as const + } + } + }, + versionMetadata: { + binding: 'CF_VERSION_METADATA' + }, + hyperdrive: { + POSTGRES: { + id: 'hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + }, + workerLoaders: { + LOADER: {} + }, + mtlsCertificates: { + API_CERT: 'cert-123' + }, + dispatchNamespaces: { + DISPATCHER: 'tenants' + }, + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow' + } + }, + pipelines: { + EVENTS: 'events-stream' + }, + images: { + IMAGES: true as const + }, + media: { + MEDIA: true as const + }, + artifacts: { + ARTIFACTS: 'default' + }, + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + }, + aiSearch: { + BLOG_SEARCH: { + instanceName: 'blog' + } + }, + aiSearchNamespaces: { + SEARCH: { + namespace: 'default' + } + }, + ai: { + binding: 'AI', + remote: true + }, + vectorize: { + DOCUMENTS: { + indexName: 'docs', + remote: true + } + } + } + } + + test('derives deterministic pure-test bindings from devflare config', async () => { + const result = createOfflineBindings(config, { + secretsStore: { + API_TOKEN: 'offline-secret' + }, + mtlsCertificates: { + API_CERT: () => new Response('cert fetch') + }, + dispatchNamespaces: { + DISPATCHER: { + workers: { + tenant: () => new Response('tenant response') + } + } + }, + aiSearch: { + BLOG_SEARCH: { + items: [ + { + key: 'cache.md', + content: 'Cloudflare cache API stores responses', + metadata: { slug: '/cache' } + } + ] + } + }, + aiSearchNamespaces: { + SEARCH: { + instances: { + docs: { + items: [ + { + key: 'offline.md', + content: 'Offline support is fixture backed' + } + ] + } + } + } + } + }) + + expect(result.env.PUBLIC_VALUE).toBe('local') + expect(await (result.env.API_TOKEN as SecretsStoreSecret).get()).toBe('offline-secret') + expect((result.env.POSTGRES as Hyperdrive).connectionString).toBe( + 'postgres://user:pass@localhost:5432/app' + ) + expect(await (await (result.env.API_CERT as Fetcher).fetch('https://example.com')).text()).toBe( + 'cert fetch' + ) + expect( + await ( + await (result.env.DISPATCHER as DispatchNamespace) + .get('tenant') + .fetch('https://example.com') + ).text() + ).toBe('tenant response') + + const firstLimit = await (result.env.RATE_LIMITER as RateLimit).limit({ key: 'user-1' }) + const secondLimit = await (result.env.RATE_LIMITER as RateLimit).limit({ key: 'user-1' }) + expect(firstLimit.success).toBe(true) + expect(secondLimit.success).toBe(false) + + await (result.env.EVENTS as Pipeline).send([{ message: 'hello' }]) + expect((result.env.EVENTS as Pipeline & { _getRecords(): unknown[] })._getRecords()).toEqual([ + { message: 'hello' } + ]) + + const search = await (result.env.BLOG_SEARCH as AiSearchInstance).search({ query: 'cache' }) + expect(search.chunks).toHaveLength(1) + expect(search.chunks[0].item.key).toBe('cache.md') + + const multi = await (result.env.SEARCH as AiSearchNamespace).search({ + query: 'offline', + ai_search_options: { + instance_ids: ['docs'] + } + }) + expect(multi.chunks).toHaveLength(1) + expect(multi.chunks[0].instance_id).toBe('docs') + + expect(result.remoteBoundaries.map((boundary) => boundary.service)).toContain('ai') + expect(result.remoteBoundaries.map((boundary) => boundary.service)).toContain('vectorize') + expect(result.missingFixtures).toEqual([]) + }) + + test('makes missing secret fixtures explicit and non-networked', async () => { + const result = createOfflineBindings(config) + + expect(result.missingFixtures).toEqual([ + { + service: 'secretsStore', + binding: 'API_TOKEN', + reason: + 'Secrets Store values are not present in fixtures or the local secret store; pass fixtures.secretsStore.API_TOKEN or run devflare secrets --local.' + } + ]) + await expect((result.env.API_TOKEN as SecretsStoreSecret).get()).rejects.toThrow( + 'fixtures.secretsStore.API_TOKEN' + ) + }) + + test('createOfflineEnv returns only the derived env object', async () => { + const env = createOfflineEnv(config, { + secretsStore: { + API_TOKEN: 'offline-secret' + } + }) + + expect(await (env.API_TOKEN as SecretsStoreSecret).get()).toBe('offline-secret') + expect(env.CF_VERSION_METADATA).toEqual({ + id: 'devflare-local-version', + tag: 'local', + timestamp: '1970-01-01T00:00:00.000Z' + }) + }) + + test('loads Secrets Store values from the local secret store when cwd is provided', async () => { + const cwd = createTempDir() + writeLocalSecret({ + cwd, + storeId: 'store-123', + name: 'api-token', + value: 'local-secret' + }) + + const env = createOfflineEnv({ + name: 'offline-secret-worker', + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + }, {}, { cwd }) + + expect(await (env.API_TOKEN as SecretsStoreSecret).get()).toBe('local-secret') + }) +}) diff --git a/packages/devflare/tests/unit/test/resolve-service-bindings.test.ts b/packages/devflare/tests/unit/test/resolve-service-bindings.test.ts new file mode 100644 index 0000000..357c0ca --- /dev/null +++ b/packages/devflare/tests/unit/test/resolve-service-bindings.test.ts @@ -0,0 +1,391 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { RefResult } from '../../../src/config/ref' +import type { DevflareConfig } from '../../../src/config/schema' +import { clearBundleCache, resolveServiceBindings } from '../../../src/test/resolve-service-bindings' + +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +beforeEach(() => { + clearBundleCache() +}) + +function createResolvedRef(config: DevflareConfig, configPath: string): RefResult { + const resolved = { + name: config.name, + config, + configPath + } + + return { + get name() { + return resolved.name + }, + get config() { + return resolved.config + }, + get configPath() { + return resolved.configPath + }, + __import: async () => ({ default: config }), + async resolve() { + return resolved + } + } as unknown as RefResult +} + +describe('resolveServiceBindings', () => { + test('discovers named entrypoints from files.entrypoints without bundling files.fetch', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-service-bindings-entrypoints-')) + tempDirs.push(projectDir) + + const workerDir = join(projectDir, 'workers', 'math') + await mkdir(join(workerDir, 'src'), { recursive: true }) + await mkdir(join(workerDir, 'rpc', 'admin'), { recursive: true }) + + await writeFile(join(workerDir, 'src', 'worker.ts'), ` +export async function defaultPing(): Promise { + return 'DEFAULT_RPC_SENTINEL' +} +`.trim()) + + await writeFile(join(workerDir, 'src', 'fetch.ts'), ` +export async function fetch(): Promise { + return new Response('FETCH_FILE_SHOULD_NOT_BE_BUNDLED') +} +`.trim()) + + await writeFile(join(workerDir, 'rpc', 'admin', 'ep.admin.ts'), ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class AdminEntrypoint extends WorkerEntrypoint { + async ping(): Promise { + return 'ENTRYPOINT_RPC_SENTINEL' + } +} +`.trim()) + + const referencedConfig = { + name: 'math-worker', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + files: { + fetch: 'src/fetch.ts', + entrypoints: 'rpc/**/ep.*.ts' + } + } as DevflareConfig + + const ref = createResolvedRef(referencedConfig, './workers/math/devflare.config.ts') + const primaryConfig = { + name: 'gateway-worker', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + bindings: { + services: { + DEFAULT: { + service: 'math-worker', + __ref: ref + }, + ADMIN: { + service: 'math-worker', + entrypoint: 'AdminEntrypoint', + __ref: ref + } + } + } + } as DevflareConfig + + const result = await resolveServiceBindings(primaryConfig, projectDir) + + expect(result.primaryServiceBindings.ADMIN).toEqual({ + name: 'math-worker', + entrypoint: 'AdminEntrypoint' + }) + expect(result.primaryServiceBindings.DEFAULT).toEqual({ + name: 'math-worker' + }) + expect(result.workers).toHaveLength(1) + expect(result.workers[0]?.script).toContain('DEFAULT_RPC_SENTINEL') + expect(result.workers[0]?.script).toContain('ENTRYPOINT_RPC_SENTINEL') + expect(result.workers[0]?.script).not.toContain('FETCH_FILE_SHOULD_NOT_BE_BUNDLED') + }) + + test('keeps default service bindings on src/worker.ts even when files.fetch points elsewhere', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-service-bindings-worker-')) + tempDirs.push(projectDir) + + const workerDir = join(projectDir, 'workers', 'rpc-worker') + await mkdir(join(workerDir, 'src'), { recursive: true }) + + await writeFile(join(workerDir, 'src', 'worker.ts'), ` +export async function serviceAnswer(): Promise { + return 'WORKER_RPC_SENTINEL' +} +`.trim()) + + await writeFile(join(workerDir, 'src', 'fetch.ts'), ` +export async function fetch(): Promise { + return new Response('FETCH_FILE_SHOULD_NOT_BE_BUNDLED') +} +`.trim()) + + const referencedConfig = { + name: 'rpc-worker', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + files: { + fetch: 'src/fetch.ts' + } + } as DevflareConfig + + const ref = createResolvedRef(referencedConfig, './workers/rpc-worker/devflare.config.ts') + const primaryConfig = { + name: 'gateway-worker', + compatibilityDate: '2026-03-17', + compatibilityFlags: ['nodejs_compat', 'nodejs_als'], + bindings: { + services: { + RPC: { + service: 'rpc-worker', + __ref: ref + } + } + } + } as DevflareConfig + + const result = await resolveServiceBindings(primaryConfig, projectDir) + + expect(result.primaryServiceBindings.RPC).toEqual({ name: 'rpc-worker' }) + expect(result.workers).toHaveLength(1) + expect(result.workers[0]?.script).toContain('WORKER_RPC_SENTINEL') + expect(result.workers[0]?.script).not.toContain('FETCH_FILE_SHOULD_NOT_BE_BUNDLED') + }) + + test('carries local runtime bindings onto referenced service workers', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-service-bindings-runtime-')) + tempDirs.push(projectDir) + + const workerDir = join(projectDir, 'workers', 'api') + await mkdir(join(workerDir, 'src'), { recursive: true }) + + await writeFile(join(workerDir, 'src', 'worker.ts'), ` +export async function ping(): Promise { + return 'PONG' +} +`.trim()) + + const referencedConfig = { + name: 'api-worker', + compatibilityDate: '2026-04-28', + compatibilityFlags: ['nodejs_compat'], + vars: { + FEATURE_FLAG: 'enabled' + }, + bindings: { + kv: { + CACHE: { name: 'api-cache' } + }, + d1: { + DB: { name: 'api-db' } + }, + r2: { + ASSETS: 'api-assets' + }, + queues: { + producers: { + JOBS: 'api-jobs' + } + } + } + } as DevflareConfig + + const ref = createResolvedRef(referencedConfig, './workers/api/devflare.config.ts') + const primaryConfig = { + name: 'site-worker', + compatibilityDate: '2026-04-28', + bindings: { + services: { + API: { + service: 'api-worker', + __ref: ref + } + } + } + } as DevflareConfig + + const result = await resolveServiceBindings(primaryConfig, projectDir) + const worker = result.workers[0] as any + + expect(worker.kvNamespaces).toEqual({ CACHE: 'api-cache' }) + expect(worker.d1Databases).toEqual({ DB: 'api-db' }) + expect(worker.r2Buckets).toEqual({ ASSETS: 'api-assets' }) + expect(worker.queueProducers).toEqual({ JOBS: { queueName: 'api-jobs' } }) + expect(worker.bindings).toEqual({ FEATURE_FLAG: 'enabled' }) + }) + + test('wires local Durable Objects owned by referenced service workers', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-service-bindings-do-')) + tempDirs.push(projectDir) + + const workerDir = join(projectDir, 'workers', 'api') + await mkdir(join(workerDir, 'src'), { recursive: true }) + + await writeFile(join(workerDir, 'src', 'ep.api.ts'), ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class ApiEntrypoint extends WorkerEntrypoint { + async ping(): Promise { + return 'PONG' + } +} +`.trim()) + + await writeFile(join(workerDir, 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async ping(): Promise { + return 'DO_PONG' + } +} +`.trim()) + + const referencedConfig = { + name: 'api-worker', + compatibilityDate: '2026-04-28', + compatibilityFlags: ['nodejs_compat'], + files: { + entrypoints: 'src/ep.*.ts', + durableObjects: 'src/do.*.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + } + } as DevflareConfig + + const ref = createResolvedRef(referencedConfig, './workers/api/devflare.config.ts') + const primaryConfig = { + name: 'site-worker', + compatibilityDate: '2026-04-28', + bindings: { + services: { + API: { + service: 'api-worker', + entrypoint: 'ApiEntrypoint', + __ref: ref + } + } + } + } as DevflareConfig + + const result = await resolveServiceBindings(primaryConfig, projectDir) + const apiWorker = result.workers.find((worker) => worker.name === 'api-worker') + const doWorker = result.workers.find((worker) => worker.name === 'api-worker-durable-objects') + + expect(apiWorker?.durableObjects).toEqual({ + COUNTER: { + className: 'Counter', + scriptName: 'api-worker-durable-objects' + } + }) + expect(doWorker?.durableObjects).toEqual({ COUNTER: 'Counter' }) + expect(doWorker?.script).toContain('DO_PONG') + }) + + test('does not duplicate queue consumers onto auxiliary durable object workers', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-service-bindings-surfaces-')) + tempDirs.push(projectDir) + + const workerDir = join(projectDir, 'workers', 'api') + await mkdir(join(workerDir, 'src'), { recursive: true }) + + await writeFile(join(workerDir, 'src', 'ep.api.ts'), ` +import { WorkerEntrypoint } from 'cloudflare:workers' + +export class ApiEntrypoint extends WorkerEntrypoint { + async fetch(): Promise { + return new Response('API_OK') + } +} +`.trim()) + + await writeFile(join(workerDir, 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + async fetch(): Promise { + return new Response('DO_OK') + } +} +`.trim()) + + const referencedConfig = { + name: 'api-worker', + compatibilityDate: '2026-04-28', + files: { + entrypoints: 'src/ep.*.ts', + durableObjects: 'src/do.*.ts' + }, + bindings: { + durableObjects: { + COUNTER: 'Counter' + }, + queues: { + producers: { + EMAIL_QUEUE: 'email-local', + TTS_QUEUE: 'tts-local' + }, + consumers: [ + { queue: 'email-local', deadLetterQueue: 'email-dlq-local' }, + { queue: 'tts-local', deadLetterQueue: 'tts-dlq-local' } + ] + } + } + } as DevflareConfig + + const ref = createResolvedRef(referencedConfig, './workers/api/devflare.config.ts') + const primaryConfig = { + name: 'site-worker', + compatibilityDate: '2026-04-28', + bindings: { + services: { + API: { + service: 'api-worker', + entrypoint: 'ApiEntrypoint', + __ref: ref + } + } + } + } as DevflareConfig + + const result = await resolveServiceBindings(primaryConfig, projectDir) + const apiWorker = result.workers.find((worker) => worker.name === 'api-worker') + const doWorker = result.workers.find((worker) => worker.name === 'api-worker-durable-objects') + const consumersByQueue = new Map() + + for (const worker of result.workers) { + for (const queue of Object.keys(worker.queueConsumers ?? {})) { + const owners = consumersByQueue.get(queue) ?? [] + owners.push(worker.name) + consumersByQueue.set(queue, owners) + } + } + + expect(apiWorker?.queueConsumers).toEqual({ + 'email-local': { deadLetterQueue: 'email-dlq-local' }, + 'tts-local': { deadLetterQueue: 'tts-dlq-local' } + }) + expect(doWorker?.queueConsumers).toBeUndefined() + expect([...consumersByQueue.values()].filter((owners) => owners.length > 1)).toEqual([]) + }) +}) diff --git a/packages/devflare/tests/unit/test/simple-context-bindings.test.ts b/packages/devflare/tests/unit/test/simple-context-bindings.test.ts new file mode 100644 index 0000000..e955ab0 --- /dev/null +++ b/packages/devflare/tests/unit/test/simple-context-bindings.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { buildRemoteAndStaticBindings } from '../../../src/test/simple-context-bindings' +import { createMockVersionMetadata } from '../../../src/test/utilities' + +const originalRemoteMode = process.env.DEVFLARE_REMOTE +const originalApiToken = process.env.CLOUDFLARE_API_TOKEN +const originalFetch = globalThis.fetch + +afterEach(() => { + if (originalRemoteMode === undefined) { + delete process.env.DEVFLARE_REMOTE + } else { + process.env.DEVFLARE_REMOTE = originalRemoteMode + } + + if (originalApiToken === undefined) { + delete process.env.CLOUDFLARE_API_TOKEN + } else { + process.env.CLOUDFLARE_API_TOKEN = originalApiToken + } + + globalThis.fetch = originalFetch +}) + +describe('buildRemoteAndStaticBindings', () => { + test('adds deterministic Version Metadata bindings for createTestContext', () => { + const bindings = buildRemoteAndStaticBindings({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } + }) + + expect(bindings.CF_VERSION_METADATA).toEqual(createMockVersionMetadata()) + }) + + test('adds remote Vectorize bindings only when Devflare remote mode is active', () => { + const config = { + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + vectorize: { + DOCUMENTS: { + indexName: 'documents-index', + remote: true + } + } + } + } + + expect(buildRemoteAndStaticBindings(config).DOCUMENTS).toBeUndefined() + + process.env.DEVFLARE_REMOTE = '1' + const bindings = buildRemoteAndStaticBindings(config) + + expect(bindings.DOCUMENTS).toBeDefined() + expect(typeof (bindings.DOCUMENTS as VectorizeIndex).query).toBe('function') + }) + + test('adds remote AI bindings only when Devflare remote mode is active', () => { + const config = { + name: 'my-worker', + accountId: 'account-123', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + ai: { + binding: 'AI', + remote: true, + staging: true + } + } + } + + expect(buildRemoteAndStaticBindings(config).AI).toBeUndefined() + + process.env.DEVFLARE_REMOTE = '1' + const bindings = buildRemoteAndStaticBindings(config) + + expect(bindings.AI).toBeDefined() + expect(typeof (bindings.AI as Ai).run).toBe('function') + }) + + test('remote AI bindings expose AI Gateway methods in remote mode', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = [] + process.env.DEVFLARE_REMOTE = '1' + process.env.CLOUDFLARE_API_TOKEN = 'token-123' + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + requests.push({ url, init }) + + if (url.endsWith('/logs/log-1') && init?.method === 'GET') { + return Response.json({ + success: true, + result: { + id: 'log-1', + provider: 'workers-ai', + model: '@cf/test', + path: '/v1/account-123/my-gateway/workers-ai', + duration: 12, + status_code: 200, + success: true, + cached: false, + request_size: 10, + request_head_complete: true, + response_size: 20, + response_head_complete: true, + created_at: '2026-04-26T00:00:00.000Z' + } + }) + } + + if (url.endsWith('/logs/log-1') && init?.method === 'PATCH') { + return Response.json({ success: true, result: null }) + } + + return new Response('gateway response') + }) as unknown as typeof fetch + + const bindings = buildRemoteAndStaticBindings({ + name: 'my-worker', + accountId: 'account-123', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + ai: { + binding: 'AI', + remote: true + } + } + }) + + const ai = bindings.AI as Ai + const gateway = ai.gateway('my-gateway') as unknown as AiGateway + + expect(typeof gateway.patchLog).toBe('function') + expect(typeof gateway.getLog).toBe('function') + expect(typeof gateway.getUrl).toBe('function') + expect(typeof gateway.run).toBe('function') + expect(await gateway.getUrl()).toBe('https://gateway.ai.cloudflare.com/v1/account-123/my-gateway/') + expect(await gateway.getUrl('workers-ai')).toBe('https://gateway.ai.cloudflare.com/v1/account-123/my-gateway/workers-ai') + + const response = await gateway.run({ + provider: 'workers-ai', + endpoint: '@cf/test', + headers: {}, + query: { prompt: 'hi' } + }) + await gateway.patchLog('log-1', { feedback: 1 }) + const log = await gateway.getLog('log-1') + + expect(await response.text()).toBe('gateway response') + expect(log.id).toBe('log-1') + expect(requests.map((request) => [request.url, request.init?.method])).toEqual([ + ['https://gateway.ai.cloudflare.com/v1/account-123/my-gateway/', 'POST'], + ['https://api.cloudflare.com/client/v4/accounts/account-123/ai-gateway/gateways/my-gateway/logs/log-1', 'PATCH'], + ['https://api.cloudflare.com/client/v4/accounts/account-123/ai-gateway/gateways/my-gateway/logs/log-1', 'GET'] + ]) + }) +}) diff --git a/packages/devflare/tests/unit/test/simple-context-handlers.test.ts b/packages/devflare/tests/unit/test/simple-context-handlers.test.ts new file mode 100644 index 0000000..39ff036 --- /dev/null +++ b/packages/devflare/tests/unit/test/simple-context-handlers.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { configSchema } from '../../../src/config/schema' +import { resolveHandlerPaths } from '../../../src/test/simple-context-handlers' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function createTempProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'devflare-tail-handlers-')) + tempDirs.push(dir) + await mkdir(join(dir, 'src'), { recursive: true }) + return dir +} + +describe('resolveHandlerPaths', () => { + test('honors explicit files.tail', async () => { + const dir = await createTempProject() + await writeFile(join(dir, 'src', 'observability-tail.ts'), 'export async function tail() {}') + + const config = configSchema.parse({ + name: 'tail-test', + compatibilityDate: '2026-04-26', + files: { + tail: 'src/observability-tail.ts' + } + }) + + const paths = await resolveHandlerPaths(dir, config) + + expect(paths.tail).toBe('src/observability-tail.ts') + }) + + test('allows files.tail to opt out of default src/tail.ts discovery', async () => { + const dir = await createTempProject() + await writeFile(join(dir, 'src', 'tail.ts'), 'export async function tail() {}') + + const config = configSchema.parse({ + name: 'tail-test', + compatibilityDate: '2026-04-26', + files: { + tail: false + } + }) + + const paths = await resolveHandlerPaths(dir, config) + + expect(paths.tail).toBeNull() + }) +}) diff --git a/packages/devflare/tests/unit/test/simple-context-mfconfig.test.ts b/packages/devflare/tests/unit/test/simple-context-mfconfig.test.ts new file mode 100644 index 0000000..3216693 --- /dev/null +++ b/packages/devflare/tests/unit/test/simple-context-mfconfig.test.ts @@ -0,0 +1,380 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { buildInlineBridgeMfConfig } from '../../../src/test/simple-context-mfconfig' +import { writeLocalSecret } from '../../../src/secrets/local-secrets' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'devflare-simple-context-secrets-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('buildInlineBridgeMfConfig', () => { + test('adds Miniflare Rate Limiting bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + rateLimits: { + MY_RATE_LIMITER: { + namespaceId: '1001', + simple: { + limit: 100, + period: 60 + } + } + } + } + }) + + expect(mfConfig.ratelimits).toEqual({ + MY_RATE_LIMITER: { + simple: { + limit: 100, + period: 60 + } + } + }) + }) + + test('adds Miniflare Version Metadata binding for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + versionMetadata: { + binding: 'CF_VERSION_METADATA' + } + } + }) + + expect(mfConfig.versionMetadata).toBe('CF_VERSION_METADATA') + }) + + test('adds Miniflare Hyperdrive bindings with local connection strings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + hyperdrive: { + POSTGRES: { + id: 'hyperdrive-id', + localConnectionString: 'postgres://user:pass@localhost:5432/app' + } + } + } + }) + + expect(mfConfig.hyperdrives).toEqual({ + POSTGRES: 'postgres://user:pass@localhost:5432/app' + }) + }) + + test('adds Miniflare Secrets Store bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + secretsStore: { + API_TOKEN: { + storeId: 'store-123', + secretName: 'api-token' + } + } + } + }) + + expect(mfConfig.secretsStoreSecrets).toEqual({ + API_TOKEN: { + store_id: 'store-123', + secret_name: 'api-token' + } + }) + }) + + test('uses the default Secrets Store id for shorthand bindings in createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token' + } + } + }) + + expect(mfConfig.secretsStoreSecrets).toEqual({ + API_TOKEN: { + store_id: 'store-123', + secret_name: 'api-token' + } + }) + }) + + test('uses wrapped bindings for createTestContext local Secrets Store values', () => { + const cwd = createTempDir() + writeLocalSecret({ cwd, storeId: 'store-123', name: 'api-token', value: 'local-secret' }) + + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + secretsStoreId: 'store-123', + bindings: { + secretsStore: { + API_TOKEN: 'api-token', + REMOTE_ONLY: 'remote-only' + } + } + }, { cwd }) + + expect(mfConfig.secretsStoreSecrets).toEqual({ + REMOTE_ONLY: { + store_id: 'store-123', + secret_name: 'remote-only' + } + }) + expect(mfConfig.wrappedBindings).toEqual({ + API_TOKEN: { + scriptName: 'devflare-local-secret-0-api-token', + bindings: { + value: 'local-secret' + } + } + }) + expect(mfConfig.__devflareLocalBindingWorkers).toHaveLength(1) + }) + + test('adds Miniflare Worker Loader bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + workerLoaders: { + LOADER: {} + } + } + }) + + expect(mfConfig.workerLoaders).toEqual({ + LOADER: {} + }) + }) + + test('adds Miniflare mTLS Certificate bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + mtlsCertificates: { + API_CERT: { + certificateId: 'cert-123' + } + } + } + }) + + expect(mfConfig.mtlsCertificates).toEqual({ + API_CERT: { + certificate_id: 'cert-123' + } + }) + }) + + test('adds Miniflare Dispatch Namespace bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + dispatchNamespaces: { + DISPATCHER: { + namespace: 'customers' + } + } + } + }) + + expect(mfConfig.dispatchNamespaces).toEqual({ + DISPATCHER: { + namespace: 'customers' + } + }) + }) + + test('adds Miniflare Workflow bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + workflows: { + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow', + scriptName: 'workflow-worker', + limits: { + steps: 42 + } + } + } + } + }) + + expect(mfConfig.workflows).toEqual({ + ORDER_WORKFLOW: { + name: 'orders', + className: 'OrderWorkflow', + scriptName: 'workflow-worker', + stepLimit: 42 + } + }) + }) + + test('adds Miniflare Pipeline bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + pipelines: { + EVENTS: 'events-stream', + AUDIT: { + pipeline: 'audit-stream' + } + } + } + }) + + expect(mfConfig.pipelines).toEqual({ + EVENTS: 'events-stream', + AUDIT: { + pipeline: 'audit-stream' + } + }) + }) + + test('adds a local Images service binding shim for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + images: { + IMAGES: { + remote: true + } + } + } + }) + + expect(mfConfig.images).toBeUndefined() + expect(mfConfig.serviceBindings).toEqual({ + IMAGES: { + name: 'devflare-local-images-0-images', + entrypoint: 'LocalImagesBinding' + } + }) + expect(mfConfig.__devflareLocalBindingWorkers).toHaveLength(1) + }) + + test('adds a local Media Transformations service binding shim for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + media: { + MEDIA: { + remote: true + } + } + } + }) + + expect(mfConfig.media).toBeUndefined() + expect(mfConfig.serviceBindings).toEqual({ + MEDIA: { + name: 'devflare-local-media-0-media', + entrypoint: 'LocalMediaBinding' + } + }) + expect(mfConfig.__devflareLocalBindingWorkers).toHaveLength(1) + }) + + test('adds Miniflare AI Search bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + aiSearchNamespaces: { + AI_SEARCH: { + namespace: 'default', + remote: true + } + }, + aiSearch: { + DOCS_SEARCH: { + instanceName: 'docs', + remote: true + } + } + } + }) + + expect(mfConfig.aiSearchNamespaces).toEqual({ + AI_SEARCH: { + namespace: 'default' + } + }) + expect(mfConfig.aiSearchInstances).toEqual({ + DOCS_SEARCH: { + instance_name: 'docs' + } + }) + }) + + test('adds Miniflare Artifacts bindings for createTestContext', () => { + const mfConfig = buildInlineBridgeMfConfig({ + name: 'my-worker', + compatibilityDate: '2026-04-26', + compatibilityFlags: [], + bindings: { + artifacts: { + ARTIFACTS: 'default', + ARCHIVE: { + namespace: 'archive' + } + } + } + }) + + expect(mfConfig.artifacts).toEqual({ + ARTIFACTS: { + namespace: 'default' + }, + ARCHIVE: { + namespace: 'archive' + } + }) + }) +}) diff --git a/packages/devflare/tests/unit/test/simple-context-runtime.test.ts b/packages/devflare/tests/unit/test/simple-context-runtime.test.ts new file mode 100644 index 0000000..5f7c21f --- /dev/null +++ b/packages/devflare/tests/unit/test/simple-context-runtime.test.ts @@ -0,0 +1,85 @@ +// ============================================================================= +// bootTestRuntime โ€” initialization-order coverage (F49) +// ============================================================================= +// Pin the contract that `bootTestRuntime` only ever takes one of two paths +// based on the `usesMultiWorker` flag, and that the bridge-backed path's +// `client` is preserved while the multi-worker path leaves it null. +// ============================================================================= + +import { describe, expect, mock, test } from 'bun:test' + +// We mock the two collaborators via module patching at import time. +const startBridgeBackedTestContextMock = mock(async (_mfConfig: any) => ({ + port: 4321, + client: { __isBridge: true } as any, + miniflare: { __label: 'bridge-mf' }, + miniflareBindings: { B: 1 } +})) + +const getAvailablePortMock = mock(async () => 5678) + +const miniflareCtorCalls: any[] = [] +class FakeMiniflare { + ready = Promise.resolve() + private _port: number + constructor(opts: any) { + miniflareCtorCalls.push(opts) + this._port = opts.port + } + async getBindings() { + return { MW: this._port } + } +} + +mock.module('../../../src/test/simple-context-startup', () => ({ + startBridgeBackedTestContext: startBridgeBackedTestContextMock +})) + +mock.module('../../../src/test/simple-context-paths', () => ({ + getAvailablePort: getAvailablePortMock, + resolveTransportFile: () => null +})) + +mock.module('miniflare', () => ({ + Miniflare: FakeMiniflare +})) + +// Note: we deliberately do NOT mock '../../../src/utils/send-email' here. +// Bun's `mock.module()` is process-global and leaks the patched module into +// every other test file that runs in the same process โ€” patching it here +// would silently corrupt e.g. tests/unit/runtime/context.test.ts which +// observes `runWithContext`'s env identity. The real `wrapEnvSendEmailBindings` +// is a no-op for envs without SendEmail bindings, which is what the fixtures +// below provide, so the bridge / multi-worker assertions below are unaffected. + +import { bootTestRuntime } from '../../../src/test/simple-context-runtime' + +describe('bootTestRuntime', () => { + test('bridge-backed path returns the bridge client and skips Miniflare boot', async () => { + startBridgeBackedTestContextMock.mockClear() + miniflareCtorCalls.length = 0 + + const result = await bootTestRuntime({ workers: [{ name: 'main' }] }, false) + + expect(startBridgeBackedTestContextMock).toHaveBeenCalledTimes(1) + expect(miniflareCtorCalls.length).toBe(0) + expect(result.activePort).toBe(4321) + expect(result.client).not.toBeNull() + expect(result.miniflare).toEqual({ __label: 'bridge-mf' }) + expect(result.miniflareBindings).toEqual({ B: 1 }) + }) + + test('multi-worker path boots Miniflare on a fresh port and returns no client', async () => { + startBridgeBackedTestContextMock.mockClear() + miniflareCtorCalls.length = 0 + + const result = await bootTestRuntime({ workers: [{ name: 'main' }, { name: 'svc' }] }, true) + + expect(startBridgeBackedTestContextMock).not.toHaveBeenCalled() + expect(miniflareCtorCalls.length).toBe(1) + expect(miniflareCtorCalls[0].port).toBe(5678) + expect(result.activePort).toBe(5678) + expect(result.client).toBeNull() + expect(result.miniflareBindings).toEqual({ MW: 5678 }) + }) +}) diff --git a/packages/devflare/tests/unit/test/tail.test.ts b/packages/devflare/tests/unit/test/tail.test.ts new file mode 100644 index 0000000..98ffafa --- /dev/null +++ b/packages/devflare/tests/unit/test/tail.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { configureTail, resetTailState, tail } from '../../../src/test/tail' + +const tempDirs: string[] = [] + +afterEach(async () => { + resetTailState() + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function createTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'devflare-tail-helper-')) + tempDirs.push(dir) + return dir +} + +describe('tail test helper', () => { + test('invokes default object tail handlers with Cloudflare native arguments', async () => { + const dir = await createTempDir() + await writeFile(join(dir, 'tail-object.mjs'), ` +export default { + async tail(events, env, ctx) { + env.calls.push({ + eventCount: events.length, + hasWaitUntil: typeof ctx.waitUntil === 'function' + }) + } +} + `.trim()) + + const env = { calls: [] as Array<{ eventCount: number; hasWaitUntil: boolean }> } + configureTail({ + handlerPath: 'tail-object.mjs', + configDir: dir, + getEnv: () => env + }) + + const result = await tail.trigger([ + { scriptName: 'producer-worker' } + ]) + + expect(result).toEqual({ success: true, itemCount: 1 }) + expect(env.calls).toEqual([ + { eventCount: 1, hasWaitUntil: true } + ]) + }) +}) diff --git a/packages/devflare/tests/unit/test/utilities.test.ts b/packages/devflare/tests/unit/test/utilities.test.ts new file mode 100644 index 0000000..5849189 --- /dev/null +++ b/packages/devflare/tests/unit/test/utilities.test.ts @@ -0,0 +1,631 @@ +// ============================================================================= +// Test Utilities Tests +// ============================================================================= + +import { describe, expect, test, mock } from 'bun:test' +import type { Pipeline } from 'cloudflare:pipelines' +import { + createMockTestContext, + createMockKV, + createMockD1, + createMockR2, + createMockRateLimit, + createMockVersionMetadata, + createMockHyperdrive, + createMockWorkerLoader, + createMockMTLSCertificate, + createMockDispatchNamespace, + createMockWorkflow, + createMockPipeline, + createMockImagesBinding, + createMockMediaBinding, + createMockArtifacts, + createMockSecretsStoreSecret, + createMockEnv, + withTestContext, + type TestContextOptions +} from '../../../src/test/utilities' +import { getContext, hasContext } from '../../../src/runtime/context' +import { env, locals } from '../../../src/runtime/exports' + +describe('createMockTestContext', () => { + test('creates context with default env', () => { + const ctx = createMockTestContext() + + expect(ctx.env).toBeDefined() + expect(ctx.ctx).toBeDefined() + expect(ctx.request).toBeNull() + }) + + test('accepts custom env', () => { + const customEnv = { MY_VAR: 'test-value', DB: {} } + const ctx = createMockTestContext({ env: customEnv }) + + expect(ctx.env.MY_VAR).toBe('test-value') + }) + + test('accepts custom request', () => { + const request = new Request('https://test.com/api') + const ctx = createMockTestContext({ request }) + + expect(ctx.request).toBe(request) + }) + + test('provides mock ExecutionContext', () => { + const ctx = createMockTestContext() + + expect(typeof ctx.ctx.waitUntil).toBe('function') + expect(typeof ctx.ctx.passThroughOnException).toBe('function') + }) + + test('waitUntil collects promises', () => { + const ctx = createMockTestContext() + + ctx.ctx.waitUntil(Promise.resolve('task1')) + ctx.ctx.waitUntil(Promise.resolve('task2')) + + expect(ctx.waitUntilPromises).toHaveLength(2) + }) +}) + +describe('withTestContext', () => { + test('runs function within context', async () => { + let hadContext = false + + await withTestContext({}, async () => { + hadContext = hasContext() + }) + + expect(hadContext).toBe(true) + }) + + test('provides access to env proxy', async () => { + const mockEnv = { API_KEY: 'secret123' } + + await withTestContext({ env: mockEnv }, async () => { + expect((env as Record).API_KEY).toBe('secret123') + }) + }) + + test('provides access to locals', async () => { + await withTestContext({}, async () => { + ; (locals as Record).userId = 'user-123' + expect(locals.userId).toBe('user-123') + }) + }) + + test('returns handler result', async () => { + const result = await withTestContext({}, async () => { + return new Response('Test Response') + }) + + expect(await result.text()).toBe('Test Response') + }) + + test('context is unavailable after handler', async () => { + await withTestContext({}, async () => { + expect(hasContext()).toBe(true) + }) + + expect(hasContext()).toBe(false) + }) +}) + +describe('createMockKV', () => { + test('creates mock KV with get/put/delete', async () => { + const kv = createMockKV() + + await kv.put('key1', 'value1') + expect(await kv.get('key1')).toBe('value1') + + await kv.delete('key1') + expect(await kv.get('key1')).toBeNull() + }) + + test('supports json type', async () => { + const kv = createMockKV() + + await kv.put('data', JSON.stringify({ foo: 'bar' })) + const result = await kv.get('data', { type: 'json' }) + + expect(result).toEqual({ foo: 'bar' }) + }) + + test('supports list operation', async () => { + const kv = createMockKV() + + await kv.put('prefix:a', '1') + await kv.put('prefix:b', '2') + await kv.put('other', '3') + + const result = await kv.list({ prefix: 'prefix:' }) + + expect(result.keys).toHaveLength(2) + expect(result.list_complete).toBe(true) + }) + + test('pre-populates with initial data', async () => { + const kv = createMockKV({ + 'key1': 'value1', + 'key2': JSON.stringify({ nested: true }) + }) + + expect(await kv.get('key1')).toBe('value1') + }) +}) + +describe('createMockD1', () => { + test('creates mock D1 with exec', async () => { + const d1 = createMockD1() + + const result = await d1.exec('CREATE TABLE test (id INTEGER)') + + expect(result).toBeDefined() + }) + + test('supports prepare().all()', async () => { + const d1 = createMockD1([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ]) + + const stmt = d1.prepare('SELECT * FROM users') + const result = await stmt.all() + + expect(result.results).toHaveLength(2) + expect(result.results[0].name).toBe('Alice') + }) + + test('supports prepare().first()', async () => { + const d1 = createMockD1([ + { id: 1, name: 'Alice' } + ]) + + const stmt = d1.prepare('SELECT * FROM users WHERE id = ?') + const result = await stmt.bind(1).first() + + expect(result?.name).toBe('Alice') + }) + + test('supports prepare().run()', async () => { + const d1 = createMockD1() + + const stmt = d1.prepare('INSERT INTO users (name) VALUES (?)') + const result = await stmt.bind('Charlie').run() + + expect(result.success).toBe(true) + }) + + test('returns per-table fixtures on SELECT FROM

', async () => { + const d1 = createMockD1({ + fixtures: { + users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], + posts: [{ id: 10, title: 'Hello' }] + } + }) + + const users = await d1.prepare('SELECT * FROM users').all() + expect(users.results).toHaveLength(2) + expect((users.results[0] as { name: string }).name).toBe('Alice') + + const posts = await d1.prepare('SELECT id, title FROM posts WHERE id = ?').bind(10).all() + expect(posts.results).toHaveLength(1) + expect((posts.results[0] as { title: string }).title).toBe('Hello') + }) + + test('different queries against different tables return distinct fixtures', async () => { + const d1 = createMockD1({ + fixtures: { + users: [{ id: 1, name: 'Alice' }], + posts: [{ id: 10, title: 'Hello' }, { id: 11, title: 'World' }] + } + }) + + const firstUser = await d1.prepare('SELECT * FROM users').first<{ name: string }>() + const firstPost = await d1.prepare('SELECT * FROM posts').first<{ title: string }>() + + expect(firstUser?.name).toBe('Alice') + expect(firstPost?.title).toBe('Hello') + }) + + test('INSERT INTO
appends to fixture table and reflects in SELECT', async () => { + const d1 = createMockD1({ fixtures: { users: [{ id: 1, name: 'Alice' }] } }) + + const before = await d1.prepare('SELECT * FROM users').all() + expect(before.results).toHaveLength(1) + + const insert = await d1 + .prepare('INSERT INTO users (name) VALUES (?)') + .bind('Bob') + .run() + expect(insert.success).toBe(true) + expect(insert.meta.changes).toBe(1) + + const after = await d1.prepare('SELECT * FROM users').all() + expect(after.results).toHaveLength(2) + }) +}) + +describe('createMockR2', () => { + test('creates mock R2 with put/get/delete', async () => { + const r2 = createMockR2() + + await r2.put('file.txt', 'content') + const obj = await r2.get('file.txt') + + expect(obj).not.toBeNull() + expect(await obj!.text()).toBe('content') + }) + + test('returns null for missing objects', async () => { + const r2 = createMockR2() + + const obj = await r2.get('nonexistent') + expect(obj).toBeNull() + }) + + test('supports head operation', async () => { + const r2 = createMockR2() + + await r2.put('file.txt', 'content') + const head = await r2.head('file.txt') + + expect(head).not.toBeNull() + expect(head!.key).toBe('file.txt') + }) + + test('supports list operation', async () => { + const r2 = createMockR2() + + await r2.put('a.txt', 'a') + await r2.put('b.txt', 'b') + + const result = await r2.list() + + expect(result.objects).toHaveLength(2) + }) +}) + +describe('createMockRateLimit', () => { + test('allows requests until the configured limit is reached', async () => { + const limiter = createMockRateLimit({ limit: 2, period: 60 }) + + expect(await limiter.limit({ key: 'user-1' })).toEqual({ success: true }) + expect(await limiter.limit({ key: 'user-1' })).toEqual({ success: true }) + expect(await limiter.limit({ key: 'user-1' })).toEqual({ success: false }) + expect(await limiter.limit({ key: 'user-2' })).toEqual({ success: true }) + }) +}) + +describe('createMockVersionMetadata', () => { + test('creates deterministic local Worker version metadata', () => { + expect(createMockVersionMetadata()).toEqual({ + id: 'devflare-local-version', + tag: 'local', + timestamp: '1970-01-01T00:00:00.000Z' + }) + }) +}) + +describe('createMockWorkerLoader', () => { + test('returns the configured WorkerStub from load()', () => { + const stub = { + getEntrypoint: () => ({ fetch: async () => new Response('ok') }), + getDurableObjectClass: () => ({ idFromName: () => ({ toString: () => 'id' }) }) + } as unknown as WorkerStub + const loader = createMockWorkerLoader({ stub }) + + expect(loader.load({ + compatibilityDate: '2026-04-26', + mainModule: 'index.js', + modules: { + 'index.js': 'export default {}' + } + })).toBe(stub) + }) +}) + +describe('createMockMTLSCertificate', () => { + test('creates a Fetcher backed by the configured handler', async () => { + const fetcher = createMockMTLSCertificate(async (input) => { + const request = new Request(input) + return new Response(request.url) + }) + + const response = await fetcher.fetch('https://secure.example/path') + + expect(await response.text()).toBe('https://secure.example/path') + }) +}) + +describe('createMockDispatchNamespace', () => { + test('returns a configured Fetcher from get()', async () => { + const namespace = createMockDispatchNamespace({ + workers: { + tenant: async () => new Response('tenant response') + } + }) + + const response = await namespace.get('tenant').fetch('https://tenant.example') + + expect(await response.text()).toBe('tenant response') + }) +}) + +describe('createMockSecretsStoreSecret', () => { + test('returns the configured secret value from get()', async () => { + const secret = createMockSecretsStoreSecret('super-secret') + + expect(await secret.get()).toBe('super-secret') + }) +}) + +describe('createMockHyperdrive', () => { + test('returns connection details from a local database URL', () => { + const hyperdrive = createMockHyperdrive('postgres://user:pass@localhost:5432/app') + + expect(hyperdrive.connectionString).toBe('postgres://user:pass@localhost:5432/app') + expect(hyperdrive.host).toBe('localhost') + expect(hyperdrive.port).toBe(5432) + expect(hyperdrive.user).toBe('user') + expect(hyperdrive.password).toBe('pass') + expect(hyperdrive.database).toBe('app') + }) +}) + +describe('createMockEnv', () => { + test('creates env with KV bindings', () => { + const mockEnv = createMockEnv({ + kv: ['CACHE', 'SESSIONS'] + }) as { CACHE: KVNamespace; SESSIONS: KVNamespace } + + expect(mockEnv.CACHE).toBeDefined() + expect(typeof mockEnv.CACHE.get).toBe('function') + expect(mockEnv.SESSIONS).toBeDefined() + }) + + test('creates env with D1 bindings', () => { + const mockEnv = createMockEnv({ + d1: ['DB'] + }) as { DB: D1Database } + + expect(mockEnv.DB).toBeDefined() + expect(typeof mockEnv.DB.prepare).toBe('function') + }) + + test('creates env with R2 bindings', () => { + const mockEnv = createMockEnv({ + r2: ['BUCKET'] + }) as { BUCKET: R2Bucket } + + expect(mockEnv.BUCKET).toBeDefined() + expect(typeof mockEnv.BUCKET.put).toBe('function') + }) + + test('creates env with Rate Limiting bindings', async () => { + const mockEnv = createMockEnv({ + rateLimits: { + MY_RATE_LIMITER: { limit: 1, period: 60 } + } + }) as { MY_RATE_LIMITER: RateLimit } + + expect(await mockEnv.MY_RATE_LIMITER.limit({ key: 'user-1' })).toEqual({ success: true }) + expect(await mockEnv.MY_RATE_LIMITER.limit({ key: 'user-1' })).toEqual({ success: false }) + }) + + test('creates env with Version Metadata binding', () => { + const mockEnv = createMockEnv({ + versionMetadata: 'CF_VERSION_METADATA' + }) as { CF_VERSION_METADATA: WorkerVersionMetadata } + + expect(mockEnv.CF_VERSION_METADATA).toEqual(createMockVersionMetadata()) + }) + + test('creates env with Worker Loader bindings', () => { + const mockEnv = createMockEnv({ + workerLoaders: ['LOADER'] + }) as { LOADER: WorkerLoader } + + expect(typeof mockEnv.LOADER.load).toBe('function') + expect(typeof mockEnv.LOADER.get).toBe('function') + }) + + test('creates env with mTLS Certificate bindings', async () => { + const mockEnv = createMockEnv({ + mtlsCertificates: { + API_CERT: async () => new Response('secure') + } + }) as { API_CERT: Fetcher } + + const response = await mockEnv.API_CERT.fetch('https://secure.example') + + expect(await response.text()).toBe('secure') + }) + + test('creates env with Dispatch Namespace bindings', async () => { + const mockEnv = createMockEnv({ + dispatchNamespaces: { + DISPATCHER: { + workers: { + tenant: async () => new Response('tenant') + } + } + } + }) as { DISPATCHER: DispatchNamespace } + + const response = await mockEnv.DISPATCHER.get('tenant').fetch('https://tenant.example') + + expect(await response.text()).toBe('tenant') + }) + + test('creates Workflow bindings', async () => { + const workflow = createMockWorkflow() + const created = await workflow.create({ id: 'order-1', params: { id: 1 } }) + const fetched = await workflow.get('order-1') + + expect(created.id).toBe('order-1') + expect(fetched).toBe(created) + expect(await fetched.status()).toEqual({ status: 'queued' }) + }) + + test('creates env with Workflow bindings', async () => { + const mockEnv = createMockEnv({ + workflows: ['ORDER_WORKFLOW'] + }) as { ORDER_WORKFLOW: Workflow } + + const instance = await mockEnv.ORDER_WORKFLOW.create({ id: 'order-2' }) + + expect(instance.id).toBe('order-2') + }) + + test('creates Pipeline bindings', async () => { + const pipeline = createMockPipeline() + + await pipeline.send([{ event: 'signup' }]) + + expect(pipeline._getRecords()).toEqual([{ event: 'signup' }]) + }) + + test('creates env with Pipeline bindings', async () => { + const mockEnv = createMockEnv({ + pipelines: ['EVENTS'] + }) as { EVENTS: Pipeline } + + await mockEnv.EVENTS.send([{ event: 'login' }]) + + expect((mockEnv.EVENTS as ReturnType)._getRecords()).toEqual([ + { event: 'login' } + ]) + }) + + test('creates Images bindings', async () => { + const images = createMockImagesBinding({ + info: { + format: 'image/png', + fileSize: 12, + width: 16, + height: 9 + }, + response: new Response('image', { + headers: { 'Content-Type': 'image/png' } + }) + }) + + const stream = new ReadableStream() + const info = await images.info(stream) + const result = await images.input(stream).transform({ width: 16 }).output({ format: 'image/png' }) + + expect((info as { width?: number }).width).toBe(16) + expect(result.contentType()).toBe('image/png') + expect(await result.response().text()).toBe('image') + }) + + test('creates env with Images bindings', async () => { + const mockEnv = createMockEnv({ + images: 'IMAGES' + }) as { IMAGES: ImagesBinding } + + const response = (await mockEnv.IMAGES + .input(new ReadableStream()) + .output({ format: 'image/png' })).response() + + expect(response.headers.get('Content-Type')).toBe('image/png') + }) + + test('creates Media Transformations bindings', async () => { + const media = createMockMediaBinding({ + response: new Response('media', { + headers: { 'Content-Type': 'video/mp4' } + }) + }) + + const result = media + .input(new ReadableStream()) + .transform({ width: 480, height: 270 }) + .output({ mode: 'video', duration: '5s' }) + + expect(await result.contentType()).toBe('video/mp4') + expect(await (await result.response()).text()).toBe('media') + }) + + test('creates env with Media Transformations bindings', async () => { + const mockEnv = createMockEnv({ + media: 'MEDIA' + }) as { MEDIA: MediaBinding } + + const response = await mockEnv.MEDIA + .input(new ReadableStream()) + .output({ mode: 'audio' }) + .response() + + expect(response.headers.get('Content-Type')).toBe('video/mp4') + }) + + test('creates Artifacts bindings', async () => { + const artifacts = createMockArtifacts() + + const created = await artifacts.create('starter-repo', { + description: 'Repository for tests' + }) + const repo = await artifacts.get('starter-repo') + const listed = await artifacts.list() + + expect(created.name).toBe('starter-repo') + expect(repo.name).toBe('starter-repo') + expect(listed.repos.map((entry) => entry.name)).toEqual(['starter-repo']) + }) + + test('creates env with Artifacts bindings', async () => { + const mockEnv = createMockEnv({ + artifacts: ['ARTIFACTS'] + }) as { ARTIFACTS: Artifacts } + + await mockEnv.ARTIFACTS.create('starter-repo') + + expect((await mockEnv.ARTIFACTS.list()).total).toBe(1) + }) + + test('creates env with Secrets Store bindings', async () => { + const mockEnv = createMockEnv({ + secretsStore: { + API_TOKEN: 'super-secret' + } + }) as { API_TOKEN: SecretsStoreSecret } + + expect(await mockEnv.API_TOKEN.get()).toBe('super-secret') + }) + + test('creates env with Hyperdrive bindings', () => { + const mockEnv = createMockEnv({ + hyperdrive: { + POSTGRES: 'postgres://user:pass@localhost:5432/app' + } + }) as { POSTGRES: Hyperdrive } + + expect(mockEnv.POSTGRES.connectionString).toBe('postgres://user:pass@localhost:5432/app') + }) + + test('creates env with vars', () => { + const mockEnv = createMockEnv({ + vars: { + API_URL: 'https://api.example.com', + DEBUG: 'true' + } + }) + + expect(mockEnv.API_URL).toBe('https://api.example.com') + expect(mockEnv.DEBUG).toBe('true') + }) + + test('creates env with custom bindings', () => { + const customService = { fetch: async () => new Response() } + + const mockEnv = createMockEnv({ + custom: { + MY_SERVICE: customService + } + }) + + expect(mockEnv.MY_SERVICE).toBe(customService) + }) +}) diff --git a/packages/devflare/tests/unit/transform/durable-object.test.ts b/packages/devflare/tests/unit/transform/durable-object.test.ts new file mode 100644 index 0000000..6087758 --- /dev/null +++ b/packages/devflare/tests/unit/transform/durable-object.test.ts @@ -0,0 +1,387 @@ +// ============================================================================= +// Durable Object Transform Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + transformDurableObject, + findDurableObjectClasses, + findDurableObjectClassesDetailed, + generateWrapper +} from '../../../src/transform/durable-object' + +describe('findDurableObjectClasses', () => { + test('finds class extending DurableObject', () => { + const code = ` +export class MyCounter extends DurableObject { + async fetch(request: Request) { + return new Response('Hello') + } +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['MyCounter']) + }) + + test('finds multiple DO classes', () => { + const code = ` +export class Counter extends DurableObject { + count = 0 +} + +export class Session extends DurableObject { + data = {} +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toContain('Counter') + expect(classes).toContain('Session') + expect(classes).toHaveLength(2) + }) + + test('ignores non-DO classes', () => { + const code = ` +export class Helper { + static format() {} +} + +export class MyDO extends DurableObject { + async fetch() {} +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['MyDO']) + }) + + test('handles implements clause', () => { + const code = ` +export class MyDO extends DurableObject implements MyInterface { + async fetch() {} +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['MyDO']) + }) + + test('returns empty array for no DOs', () => { + const code = ` +export class RegularClass { + method() {} +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual([]) + }) + + test('finds class with @durableObject decorator', () => { + const code = ` +@durableObject() +export class Counter { + private count = 0 + + async increment() { + return ++this.count + } +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['Counter']) + }) + + test('finds class with @durableObject decorator and options', () => { + const code = ` +@durableObject({ alarms: true, rpc: ['increment', 'getValue'] }) +export class Timer { + private value = 0 +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['Timer']) + }) + + test('finds both decorated and extended classes', () => { + const code = ` +@durableObject() +export class DecoratedCounter { + count = 0 +} + +export class ExtendedCounter extends DurableObject { + count = 0 +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toContain('DecoratedCounter') + expect(classes).toContain('ExtendedCounter') + expect(classes).toHaveLength(2) + }) + + test('deduplicates classes with both decorator and extends', () => { + const code = ` +@durableObject() +export class Counter extends DurableObject { + count = 0 +} +` + const classes = findDurableObjectClasses(code) + expect(classes).toEqual(['Counter']) + }) +}) + +describe('findDurableObjectClassesDetailed', () => { + test('returns detailed info for extended class', () => { + const code = ` +export class Counter extends DurableObject { + count = 0 +} +` + const classes = findDurableObjectClassesDetailed(code) + expect(classes).toHaveLength(1) + expect(classes[0].name).toBe('Counter') + expect(classes[0].extendsBase).toBe(true) + expect(classes[0].hasDecorator).toBe(false) + }) + + test('returns detailed info for decorated class', () => { + const code = ` +@durableObject() +export class Counter { + count = 0 +} +` + const classes = findDurableObjectClassesDetailed(code) + expect(classes).toHaveLength(1) + expect(classes[0].name).toBe('Counter') + expect(classes[0].extendsBase).toBe(false) + expect(classes[0].hasDecorator).toBe(true) + }) + + test('parses decorator options', () => { + const code = ` +@durableObject({ alarms: true, websockets: false }) +export class Timer { + value = 0 +} +` + const classes = findDurableObjectClassesDetailed(code) + expect(classes).toHaveLength(1) + expect(classes[0].decoratorOptions?.alarms).toBe(true) + expect(classes[0].decoratorOptions?.websockets).toBe(false) + }) + + test('parses rpc array option', () => { + const code = ` +@durableObject({ rpc: ['increment', 'getValue'] }) +export class Counter { + value = 0 +} +` + const classes = findDurableObjectClassesDetailed(code) + expect(classes).toHaveLength(1) + expect(classes[0].decoratorOptions?.rpc).toEqual(['increment', 'getValue']) + }) +}) + +describe('generateWrapper', () => { + test('generates wrapper with context injection', () => { + const wrapper = generateWrapper('MyCounter') + + expect(wrapper).toContain('class MyCounterWrapper') + expect(wrapper).toContain('extends __OriginalMyCounter') + expect(wrapper).toContain('createDurableObjectFetchEvent') + expect(wrapper).toContain('runWithEventContext') + expect(wrapper).toContain('async fetch(request') + }) + + test('wrapper preserves original class export', () => { + const wrapper = generateWrapper('Session') + + // Should export the wrapper as the original name + expect(wrapper).toContain('export { SessionWrapper as Session }') + }) + + test('generates alarm handler when alarms option is true', () => { + const wrapper = generateWrapper('Timer', { alarms: true }) + + expect(wrapper).toContain('async alarm(') + expect(wrapper).toContain('createDurableObjectAlarmEvent') + expect(wrapper).toContain('runWithEventContext') + }) + + test('generates webSocketMessage handler when websockets option is true', () => { + const wrapper = generateWrapper('Chat', { websockets: true }) + + expect(wrapper).toContain('async webSocketMessage(') + expect(wrapper).toContain('async webSocketClose(') + expect(wrapper).toContain('async webSocketError(') + }) + + test('generates both alarm and websocket handlers', () => { + const wrapper = generateWrapper('RealtimeTimer', { alarms: true, websockets: true }) + + expect(wrapper).toContain('async alarm(') + expect(wrapper).toContain('async webSocketMessage(') + }) + + test('omits handlers when options are false', () => { + const wrapper = generateWrapper('Basic', { alarms: false, websockets: false }) + + expect(wrapper).not.toContain('async alarm(') + expect(wrapper).not.toContain('async webSocketMessage(') + }) +}) + +describe('transformDurableObject', () => { + test('transforms simple DO class', async () => { + const code = ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject { + private count = 0 + + async fetch(request: Request): Promise { + this.count++ + return new Response(String(this.count)) + } +} +` + const result = await transformDurableObject(code, 'counter.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('CounterWrapper') + expect(result?.code).toContain('createDurableObjectFetchEvent') + expect(result?.code).toContain('runWithEventContext') + }) + + test('returns null for non-DO code', async () => { + const code = ` +export function helper() { + return 'hello' +} +` + const result = await transformDurableObject(code, 'helper.ts') + expect(result).toBeNull() + }) + + test('includes source map', async () => { + const code = ` +export class MyDO extends DurableObject { + async fetch(request: Request) { + return new Response('OK') + } +} +` + const result = await transformDurableObject(code, 'do.ts') + + expect(result?.map).toBeDefined() + }) + + test('preserves non-DO exports', async () => { + const code = ` +export const VERSION = '1.0.0' + +export class MyDO extends DurableObject { + async fetch() { + return new Response(VERSION) + } +} + +export function helper() {} +` + const result = await transformDurableObject(code, 'mixed.ts') + + expect(result?.code).toContain('VERSION') + expect(result?.code).toContain('helper') + }) + + test('transforms decorated class without extends', async () => { + const code = ` +import { durableObject } from 'devflare' + +@durableObject() +export class Counter { + private count = 0 + + async fetch(request: Request): Promise { + this.count++ + return new Response(String(this.count)) + } +} +` + const result = await transformDurableObject(code, 'counter.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('CounterWrapper') + expect(result?.code).toContain('createDurableObjectFetchEvent') + expect(result?.code).toContain('runWithEventContext') + }) + + test('transforms decorated class with alarms option', async () => { + const code = ` +import { durableObject } from 'devflare' + +@durableObject({ alarms: true }) +export class Timer { + private deadline = 0 + + async fetch(request: Request): Promise { + return new Response('OK') + } + + async alarm() { + console.log('Alarm triggered') + } +} +` + const result = await transformDurableObject(code, 'timer.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('TimerWrapper') + expect(result?.code).toContain('async alarm(') + }) + + test('transforms decorated class with websockets option', async () => { + const code = ` +import { durableObject } from 'devflare' + +@durableObject({ websockets: true }) +export class ChatRoom { + async fetch(request: Request): Promise { + return new Response('OK') + } + + async webSocketMessage(ws: WebSocket, message: string) { + // handle message + } +} +` + const result = await transformDurableObject(code, 'chat.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('ChatRoomWrapper') + expect(result?.code).toContain('async webSocketMessage(') + }) + + test('transforms multiple decorated classes', async () => { + const code = ` +import { durableObject } from 'devflare' + +@durableObject() +export class Counter { + count = 0 + async fetch() { return new Response('counter') } +} + +@durableObject({ alarms: true }) +export class Timer { + deadline = 0 + async fetch() { return new Response('timer') } +} +` + const result = await transformDurableObject(code, 'multi.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('CounterWrapper') + expect(result?.code).toContain('TimerWrapper') + }) +}) diff --git a/packages/devflare/tests/unit/transform/worker-entrypoint.test.ts b/packages/devflare/tests/unit/transform/worker-entrypoint.test.ts new file mode 100644 index 0000000..3ad6235 --- /dev/null +++ b/packages/devflare/tests/unit/transform/worker-entrypoint.test.ts @@ -0,0 +1,372 @@ +// ============================================================================= +// Worker Entrypoint Transform Tests +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + findExportedFunctions, + shouldTransformWorker, + transformWorkerEntrypoint, + generateRpcInterface +} from '../../../src/transform/worker-entrypoint' + +describe('findExportedFunctions', () => { + test('finds single exported function', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + const functions = findExportedFunctions(code) + expect(functions).toHaveLength(1) + expect(functions[0].name).toBe('add') + expect(functions[0].isAsync).toBe(false) + expect(functions[0].params).toBe('a: number, b: number') + expect(functions[0].returnType).toBe('number') + }) + + test('finds async exported function', () => { + const code = ` +export async function fetchData(url: string): Promise { + return fetch(url) +} +` + const functions = findExportedFunctions(code) + expect(functions).toHaveLength(1) + expect(functions[0].name).toBe('fetchData') + expect(functions[0].isAsync).toBe(true) + }) + + test('finds multiple exported functions', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} + +export function multiply(a: number, b: number): number { + return a * b +} + +export async function divide(a: number, b: number): Promise { + return a / b +} +` + const functions = findExportedFunctions(code) + expect(functions).toHaveLength(3) + expect(functions.map((f) => f.name)).toEqual(['add', 'multiply', 'divide']) + }) + + test('finds fetch handler', () => { + const code = ` +export function fetch(request: Request, env: Env, ctx: ExecutionContext): Response { + return new Response('Hello') +} +` + const functions = findExportedFunctions(code) + expect(functions).toHaveLength(1) + expect(functions[0].name).toBe('fetch') + }) + + test('returns empty array for no exports', () => { + const code = ` +function internal() {} +const value = 42 +` + const functions = findExportedFunctions(code) + expect(functions).toEqual([]) + }) + + test('ignores non-function exports', () => { + const code = ` +export const VERSION = '1.0.0' +export class MyClass {} +export function add(a: number, b: number) { return a + b } +` + const functions = findExportedFunctions(code) + expect(functions).toHaveLength(1) + expect(functions[0].name).toBe('add') + }) +}) + +describe('shouldTransformWorker', () => { + test('returns true for worker.ts with exported functions', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + expect(shouldTransformWorker(code, 'src/worker.ts')).toBe(true) + expect(shouldTransformWorker(code, '/path/to/worker.ts')).toBe(true) + }) + + test('returns false for non-worker files', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + expect(shouldTransformWorker(code, 'src/utils.ts')).toBe(false) + expect(shouldTransformWorker(code, 'src/fetch.ts')).toBe(false) + }) + + test('returns false for worker.ts without exported functions', () => { + const code = ` +export const VERSION = '1.0.0' +class InternalClass {} +` + expect(shouldTransformWorker(code, 'src/worker.ts')).toBe(false) + }) +}) + +describe('transformWorkerEntrypoint', () => { + test('transforms single RPC function', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain("import { WorkerEntrypoint } from 'cloudflare:workers'") + expect(result?.code).toContain('class Worker extends WorkerEntrypoint') + expect(result?.code).toContain('add(a: number, b: number)') + expect(result?.rpcMethods).toEqual(['add']) + expect(result?.className).toBe('Worker') + }) + + test('transforms multiple RPC functions', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} + +export function multiply(a: number, b: number): number { + return a * b +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('add(a: number, b: number)') + expect(result?.code).toContain('multiply(a: number, b: number)') + expect(result?.rpcMethods).toEqual(['add', 'multiply']) + }) + + test('transforms fetch handler with context injection', () => { + const code = ` +export function fetch(request: Request, env: Env, ctx: ExecutionContext): Response { + return new Response('Hello') +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain("import { createFetchEvent, invokeFetchHandler, runWithEventContext } from 'devflare/runtime'") + expect(result?.code).toContain('async fetch(request: Request): Promise') + expect(result?.code).toContain('createFetchEvent(request, this.env, this.ctx)') + expect(result?.code).toContain('runWithEventContext') + expect(result?.code).toContain('invokeFetchHandler(__originalFetch, __devflareEvent)') + expect(result?.code).toContain('__originalFetch') + }) + + test('transforms both fetch and RPC methods', () => { + const code = ` +export function fetch(request: Request): Response { + return new Response('Gateway') +} + +export function calculate(x: number): number { + return x * 2 +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('async fetch(request: Request)') + expect(result?.code).toContain('calculate(x: number)') + expect(result?.rpcMethods).toEqual(['calculate']) + }) + + test('uses custom class name', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts', { className: 'MathService' }) + + expect(result).not.toBeNull() + expect(result?.code).toContain('class MathService extends WorkerEntrypoint') + expect(result?.className).toBe('MathService') + }) + + test('can disable context injection', () => { + const code = ` +export function fetch(request: Request): Response { + return new Response('Hello') +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts', { injectContext: false }) + + expect(result).not.toBeNull() + expect(result?.code).not.toContain('runWithEventContext') + expect(result?.code).toContain('createFetchEvent(request, this.env, this.ctx)') + expect(result?.code).toContain('invokeFetchHandler(__originalFetch, __devflareEvent)') + }) + + test('supports event-first fetch handlers', () => { + const code = ` +import type { FetchEvent } from 'devflare/runtime' + +export function fetch({ request }: FetchEvent): Response { + return new Response(request.url) +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result).not.toBeNull() + expect(result?.code).toContain('const __devflareEvent = createFetchEvent(request, this.env, this.ctx)') + expect(result?.code).toContain('invokeFetchHandler(__originalFetch, __devflareEvent)') + }) + + test('returns null for empty code', () => { + const result = transformWorkerEntrypoint('', 'worker.ts') + expect(result).toBeNull() + }) + + test('includes source map', () => { + const code = ` +export function add(a: number, b: number): number { + return a + b +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result?.map).toBeDefined() + expect(result?.map.sources).toContain('worker.ts') + }) + + test('preserves non-exported code', () => { + const code = ` +const MULTIPLIER = 2 + +function internalHelper(x: number) { + return x * MULTIPLIER +} + +export function double(n: number): number { + return internalHelper(n) +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + + expect(result?.code).toContain('const MULTIPLIER = 2') + expect(result?.code).toContain('function internalHelper') + }) +}) + +describe('generateRpcInterface', () => { + test('generates interface for RPC methods', () => { + const functions = findExportedFunctions(` +export function add(a: number, b: number): number { return a + b } +export function multiply(a: number, b: number): number { return a * b } +`) + const iface = generateRpcInterface(functions, 'MathService') + + expect(iface).toContain('export interface MathService') + expect(iface).toContain('add(a: number, b: number): Promise') + expect(iface).toContain('multiply(a: number, b: number): Promise') + }) + + test('excludes fetch from interface', () => { + const functions = findExportedFunctions(` +export function fetch(request: Request): Response { return new Response() } +export function add(a: number, b: number): number { return a + b } +`) + const iface = generateRpcInterface(functions, 'MyWorker') + + expect(iface).not.toContain('fetch') + expect(iface).toContain('add') + }) + + test('returns empty string for only fetch', () => { + const functions = findExportedFunctions(` +export function fetch(request: Request): Response { return new Response() } +`) + const iface = generateRpcInterface(functions, 'MyWorker') + + expect(iface).toBe('') + }) + + test('wraps non-Promise returns in Promise', () => { + const functions = findExportedFunctions(` +export function getValue(): number { return 42 } +export async function fetchValue(): Promise { return 'hello' } +`) + const iface = generateRpcInterface(functions, 'DataService') + + expect(iface).toContain('getValue(): Promise') + expect(iface).toContain('fetchValue(): Promise') + }) +}) + +describe('transformWorkerEntrypoint (JS inputs)', () => { + test('omits TS-only syntax when transforming a .js worker', () => { + const code = ` +export function fetch(request) { + return new Response('hello') +} + +export function add(a, b) { + return a + b +} +` + const result = transformWorkerEntrypoint(code, 'src/worker.js') + + expect(result).not.toBeNull() + const out = result?.code ?? '' + + // No TS interface declarations may be injected into a JS file. + expect(out).not.toMatch(/\binterface\s+\w+/) + + // No TS type annotations on the generated fetch/RPC signatures. + expect(out).not.toContain(': Request') + expect(out).not.toContain(': Promise') + expect(out).not.toMatch(/\badd\(a:\s*/) + + // JS-safe signatures are emitted instead. + expect(out).toContain('async fetch(request)') + expect(out).toContain('add(a, b)') + expect(out).toContain('return __original_add(a, b)') + expect(result?.rpcMethods).toEqual(['add']) + }) + + test('shouldTransformWorker accepts the full extension matrix', () => { + const code = `export function ping() { return 'pong' }\n` + for (const ext of ['ts', 'tsx', 'mts', 'cts', 'js', 'mjs', 'cjs']) { + expect(shouldTransformWorker(code, `src/worker.${ext}`)).toBe(true) + } + expect(shouldTransformWorker(code, 'src/other.js')).toBe(false) + }) + + test('does not rewrite matching text inside comments or strings', () => { + const code = ` +// export function fake(a: number): number { return a } +const note = 'export function bogus() {}' + +export function real(n: number): number { + return n +} +` + const result = transformWorkerEntrypoint(code, 'worker.ts') + expect(result).not.toBeNull() + const out = result?.code ?? '' + + // The commented-out and stringified export forms must survive untouched. + expect(out).toContain('// export function fake(a: number): number { return a }') + expect(out).toContain(`const note = 'export function bogus() {}'`) + // And the real export is rewritten to its internal name. + expect(out).toContain('function __original_real') + }) +}) diff --git a/packages/devflare/tests/unit/vite/plugin-transform.test.ts b/packages/devflare/tests/unit/vite/plugin-transform.test.ts new file mode 100644 index 0000000..4b636bc --- /dev/null +++ b/packages/devflare/tests/unit/vite/plugin-transform.test.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Vite plugin transform โ€” order/skip regression tests (F35) +// ============================================================================= +// Pin the public contract of `runDevflareTransform` and the two step helpers +// so the worker-entry vs DO transform separation cannot silently regress. +// ============================================================================= + +import { describe, expect, test } from 'bun:test' +import { + isTransformCandidate, + runDevflareTransform, + runDurableObjectTransform, + runWorkerEntryTransform +} from '../../../src/vite/plugin-transform' + +describe('isTransformCandidate', () => { + test('rejects node_modules paths', () => { + expect(isTransformCandidate('/repo/node_modules/foo/index.ts')).toBe(false) + }) + + test('rejects non-source extensions', () => { + expect(isTransformCandidate('/repo/src/styles.css')).toBe(false) + expect(isTransformCandidate('/repo/src/data.json')).toBe(false) + }) + + test('accepts ts/tsx/js source files', () => { + expect(isTransformCandidate('/repo/src/foo.ts')).toBe(true) + expect(isTransformCandidate('/repo/src/foo.tsx')).toBe(true) + expect(isTransformCandidate('/repo/src/foo.js')).toBe(true) + }) +}) + +describe('runWorkerEntryTransform', () => { + test('returns null for non-worker files', async () => { + const result = await runWorkerEntryTransform( + 'export default {}', + '/repo/src/foo.ts' + ) + expect(result).toBeNull() + }) + + test('returns null when worker source has no recognized handlers', async () => { + const result = await runWorkerEntryTransform( + '// nothing useful here', + '/repo/src/worker.ts' + ) + expect(result).toBeNull() + }) +}) + +describe('runDurableObjectTransform', () => { + test('returns null when doTransforms is disabled', async () => { + const code = 'import { DurableObject } from "cloudflare:workers"\nexport class C extends DurableObject {}' + const result = await runDurableObjectTransform(code, '/repo/src/c.ts', { doTransforms: false }) + expect(result).toBeNull() + }) + + test('returns null when source does not mention DurableObject', async () => { + const result = await runDurableObjectTransform( + 'export const x = 1', + '/repo/src/x.ts', + { doTransforms: true } + ) + expect(result).toBeNull() + }) +}) + +describe('runDevflareTransform โ€” order', () => { + test('skips node_modules entirely', async () => { + const result = await runDevflareTransform( + 'export class C extends DurableObject {}', + '/repo/node_modules/pkg/worker.ts', + { doTransforms: true } + ) + expect(result).toBeNull() + }) + + test('skips non-source files entirely', async () => { + const result = await runDevflareTransform( + '.foo { color: red }', + '/repo/src/styles.css', + { doTransforms: true } + ) + expect(result).toBeNull() + }) + + test('returns null for ordinary modules with no DO marker', async () => { + const result = await runDevflareTransform( + 'export const x = 1', + '/repo/src/util.ts', + { doTransforms: true } + ) + expect(result).toBeNull() + }) + + test('worker-entry step runs before DO step for worker.ts files', async () => { + // worker.ts files with no recognized handler should fall through to the DO step. + // This pins the documented order: worker-entry first, DO second. + const code = 'export const placeholder = 1' + const workerResult = await runDevflareTransform(code, '/repo/src/worker.ts', { doTransforms: true }) + // No handler => worker step yields null, DO step also yields null (no DO marker). + expect(workerResult).toBeNull() + }) +}) diff --git a/packages/devflare/tests/unit/vite/plugin.test.ts b/packages/devflare/tests/unit/vite/plugin.test.ts new file mode 100644 index 0000000..24ad9ac --- /dev/null +++ b/packages/devflare/tests/unit/vite/plugin.test.ts @@ -0,0 +1,39 @@ +// ============================================================================= +// Vite Plugin Tests +// ============================================================================= + +import { describe, expect, test, mock, beforeEach } from 'bun:test' +import { devflarePlugin, type DevflarePluginOptions } from '../../../src/vite/plugin' + +describe('devflarePlugin', () => { + test('returns valid vite plugin object', () => { + const plugin = devflarePlugin() + + expect(plugin.name).toBe('devflare') + expect(typeof plugin.configResolved).toBe('function') + }) + + test('accepts custom config path', () => { + const plugin = devflarePlugin({ + configPath: 'custom.config.ts' + }) + + expect(plugin).toBeDefined() + }) + + test('has correct hook order enforcement', () => { + const plugin = devflarePlugin() + + // Should run before @cloudflare/vite-plugin + expect(plugin.enforce).toBe('pre') + }) +}) + +describe('DO Transform Integration', () => { + // These tests will validate the Durable Object transformation + // once we implement the transform module + + test('placeholder for DO transform tests', () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/devflare/tests/unit/worker-entry/composed-worker.test.ts b/packages/devflare/tests/unit/worker-entry/composed-worker.test.ts new file mode 100644 index 0000000..4574fe2 --- /dev/null +++ b/packages/devflare/tests/unit/worker-entry/composed-worker.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { isAbsolute, join } from 'pathe' +import { configSchema } from '../../../src/config/schema' +import { prepareComposedWorkerEntrypoint } from '../../../src/worker-entry/composed-worker' + +const TEST_DIR = join(import.meta.dirname, '../.fixtures/composed-worker') + +describe('prepareComposedWorkerEntrypoint', () => { + beforeEach(async () => { + await mkdir(join(TEST_DIR, '.adapter-cloudflare'), { recursive: true }) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('skips composition for adapter-generated fetch workers that already live in assets.directory', async () => { + await writeFile(join(TEST_DIR, '.adapter-cloudflare', '_worker.js'), 'export default { fetch() { return new Response("ok") } }') + + const config = configSchema.parse({ + name: 'documentation', + compatibilityDate: '2026-04-08', + files: { + fetch: '.adapter-cloudflare/_worker.js' + }, + assets: { + directory: '.adapter-cloudflare', + binding: 'ASSETS' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + + expect(composedEntry).toBeNull() + }) + + test('re-exports local Durable Object classes from the composed worker entry', async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +export async function fetch(): Promise { + return new Response('ok') +} + `.trim()) + await writeFile(join(TEST_DIR, 'src', 'do.counter.ts'), ` +import { DurableObject } from 'cloudflare:workers' + +export class Counter extends DurableObject {} + `.trim()) + + const config = configSchema.parse({ + name: 'do-composition-test', + compatibilityDate: '2026-04-08', + bindings: { + durableObjects: { + COUNTER: 'Counter' + } + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry).not.toBeNull() + expect(composedEntry && isAbsolute(composedEntry)).toBe(true) + expect(composedEntry).toBe(join(TEST_DIR, '.devflare/worker-entrypoints/main.ts')) + + if (!composedEntry) { + throw new Error('Expected composed worker entry to be generated') + } + + const source = await readFile(composedEntry, 'utf-8') + expect(source).toContain("export { Counter } from '../../src/do.counter.ts'") + }) + + test('throws when an explicit fetch handler path is missing instead of silently falling back to src/fetch.ts', async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'src', 'fetch.ts'), ` +export async function fetch(): Promise { + return new Response('default') +} + `.trim()) + + const config = configSchema.parse({ + name: 'explicit-fetch-path-test', + compatibilityDate: '2026-04-12', + files: { + fetch: 'src/custom-fetch.ts' + } + }) + + await expect(prepareComposedWorkerEntrypoint(TEST_DIR, config)).rejects.toThrow( + 'Configured fetch handler "src/custom-fetch.ts" was not found' + ) + }) + + test('defers composition when files.fetch points at a missing build artifact and no other surface needs composition', async () => { + // SvelteKit's adapter writes .svelte-kit/cloudflare/_worker.js during vite build, AFTER + // devflare resolves surface paths. With no other surfaces, devflare should silently + // skip composition so wrangler/vite can pick up the build output post-build. + const config = configSchema.parse({ + name: 'sveltekit-adapter-passthrough', + compatibilityDate: '2026-04-12', + files: { + fetch: '.svelte-kit/cloudflare/_worker.js' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry).toBeNull() + }) + + test('throws a helpful build-artifact error when files.fetch is a build path AND other surfaces require composition', async () => { + // When other surfaces need composition, devflare cannot defer to wrangler โ€” the + // composed wrapper would have to import the missing artifact. Surface a clear error. + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'src', 'queue.ts'), ` +export async function queue(): Promise {} + `.trim()) + + const config = configSchema.parse({ + name: 'sveltekit-with-queue', + compatibilityDate: '2026-04-12', + files: { + fetch: '.svelte-kit/cloudflare/_worker.js', + queue: 'src/queue.ts' + } + }) + + await expect(prepareComposedWorkerEntrypoint(TEST_DIR, config)).rejects.toThrow( + /looks like a framework build output[\s\S]+wrangler[\s\S]+passthrough/ + ) + }) + + test('composes files.tail into a Worker tail handler', async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + await writeFile(join(TEST_DIR, 'src', 'tail.ts'), ` +export default { + async tail(events, env, ctx) { + ctx.waitUntil(Promise.resolve(events.length)) + } +} + `.trim()) + + const config = configSchema.parse({ + name: 'tail-composition-test', + compatibilityDate: '2026-04-26', + files: { + fetch: false, + tail: 'src/tail.ts' + } + }) + + const composedEntry = await prepareComposedWorkerEntrypoint(TEST_DIR, config) + expect(composedEntry).toBe(join(TEST_DIR, '.devflare/worker-entrypoints/main.ts')) + + const source = await readFile(composedEntry!, 'utf-8') + expect(source).toContain("import * as __devflareTailModule from '../../src/tail.ts'") + expect(source).toContain('async tail(events, env, ctx)') + expect(source).toContain('createTailEvent(events, env, ctx)') + }) +}) diff --git a/packages/devflare/tests/unit/worker-entry/durable-object-discovery.test.ts b/packages/devflare/tests/unit/worker-entry/durable-object-discovery.test.ts new file mode 100644 index 0000000..46ad875 --- /dev/null +++ b/packages/devflare/tests/unit/worker-entry/durable-object-discovery.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'pathe' +import { + discoverDurableObjectFiles, + discoverDurableObjects +} from '../../../src/worker-entry/durable-object-discovery' + +const TEST_DIR = join(import.meta.dirname, '../.fixtures/do-discovery') + +describe('discoverDurableObjectFiles', () => { + beforeEach(async () => { + await mkdir(join(TEST_DIR, 'src'), { recursive: true }) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + test('returns a stable map of file path โ†’ DO class names', async () => { + await writeFile( + join(TEST_DIR, 'src', 'do.chat.ts'), + 'import { DurableObject } from \'cloudflare:workers\'\nexport class ChatRoom extends DurableObject {}\n' + ) + await writeFile( + join(TEST_DIR, 'src', 'do.counter.ts'), + 'import { DurableObject } from \'cloudflare:workers\'\nexport class Counter extends DurableObject {}\nexport class Counter2 extends DurableObject {}\n' + ) + await writeFile( + join(TEST_DIR, 'src', 'do.empty.ts'), + 'export const noop = () => {}\n' + ) + + const result = await discoverDurableObjectFiles(TEST_DIR, 'src/do.*.ts') + + expect(result.size).toBe(2) + + const entries = Array.from(result.entries()).map(([path, classes]) => [ + path.replace(TEST_DIR, '').replace(/\\/g, '/'), + classes.slice().sort() + ]) + entries.sort((a, b) => String(a[0]).localeCompare(String(b[0]))) + + expect(entries).toEqual([ + ['/src/do.chat.ts', ['ChatRoom']], + ['/src/do.counter.ts', ['Counter', 'Counter2']] + ]) + }) + + test('discoverDurableObjects wraps the file map with the worker name', async () => { + await writeFile( + join(TEST_DIR, 'src', 'do.thing.ts'), + 'import { DurableObject } from \'cloudflare:workers\'\nexport class Thing extends DurableObject {}\n' + ) + + const discovery = await discoverDurableObjects(TEST_DIR, 'src/do.*.ts', 'do-worker') + + expect(discovery.workerName).toBe('do-worker') + expect(discovery.files.size).toBe(1) + expect(Array.from(discovery.files.values())[0]).toEqual(['Thing']) + }) + + test('returns an empty map when no files match', async () => { + const result = await discoverDurableObjectFiles(TEST_DIR, 'src/do.*.ts') + expect(result.size).toBe(0) + }) +}) diff --git a/packages/devflare/tests/unit/worker-entry/extensions.test.ts b/packages/devflare/tests/unit/worker-entry/extensions.test.ts new file mode 100644 index 0000000..ecdd5eb --- /dev/null +++ b/packages/devflare/tests/unit/worker-entry/extensions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { + SUPPORTED_WORKER_EXTENSIONS, + TS_WORKER_EXTENSIONS +} from '../../../src/worker-entry/extensions' +import { + DEFAULT_EMAIL_ENTRY_FILES, + DEFAULT_FETCH_ENTRY_FILES, + DEFAULT_QUEUE_ENTRY_FILES, + DEFAULT_SCHEDULED_ENTRY_FILES +} from '../../../src/worker-entry/surface-paths' + +describe('shared worker source extensions', () => { + test('SUPPORTED_WORKER_EXTENSIONS covers the documented union', () => { + expect([...SUPPORTED_WORKER_EXTENSIONS].sort()).toEqual([ + '.cjs', + '.cts', + '.js', + '.jsx', + '.mjs', + '.mts', + '.ts', + '.tsx' + ]) + }) + + test('TS_WORKER_EXTENSIONS is a strict subset of SUPPORTED_WORKER_EXTENSIONS', () => { + const supported = new Set(SUPPORTED_WORKER_EXTENSIONS) + for (const ext of TS_WORKER_EXTENSIONS) { + expect(supported.has(ext)).toBe(true) + } + }) + + test('default surface entry-file lists derive from the same shared extension set', () => { + const expected = SUPPORTED_WORKER_EXTENSIONS.length + for (const surface of [ + DEFAULT_FETCH_ENTRY_FILES, + DEFAULT_QUEUE_ENTRY_FILES, + DEFAULT_SCHEDULED_ENTRY_FILES, + DEFAULT_EMAIL_ENTRY_FILES + ]) { + expect(surface.length).toBe(expected) + const exts = surface.map((p) => p.replace(/^src\/[^.]+/, '')) + expect([...exts].sort()).toEqual([...SUPPORTED_WORKER_EXTENSIONS].sort()) + } + }) +}) diff --git a/packages/devflare/tests/unit/worker-entry/routes.test.ts b/packages/devflare/tests/unit/worker-entry/routes.test.ts new file mode 100644 index 0000000..174b2ca --- /dev/null +++ b/packages/devflare/tests/unit/worker-entry/routes.test.ts @@ -0,0 +1,103 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { type DevflareConfigInput, configSchema } from '../../../src/config' +import { DEFAULT_ROUTE_DIR, discoverRoutes } from '../../../src/worker-entry/routes' + +const tempDirs: string[] = [] + +afterAll(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +async function createTempProject(): Promise { + const projectDir = await mkdtemp(join(tmpdir(), 'devflare-routes-discovery-')) + tempDirs.push(projectDir) + return projectDir +} + +function createRouteConfig(config: DevflareConfigInput) { + return configSchema.parse({ + compatibilityDate: '2025-01-07', + ...config + }) +} + +describe('discoverRoutes', () => { + test('discovers the default src/routes directory and ignores private helper files', async () => { + const projectDir = await createTempProject() + const routesDir = join(projectDir, DEFAULT_ROUTE_DIR) + + await mkdir(join(routesDir, 'users'), { recursive: true }) + await mkdir(join(routesDir, '_internal'), { recursive: true }) + await writeFile( + join(routesDir, 'index.ts'), + 'export async function GET() { return new Response("root") }' + ) + await writeFile( + join(routesDir, 'users', 'index.ts'), + 'export async function GET() { return new Response("users") }' + ) + await writeFile( + join(routesDir, 'users', '[id].ts'), + 'export async function GET() { return new Response("user") }' + ) + await writeFile( + join(routesDir, 'users', '[...slug].ts'), + 'export async function GET() { return new Response("slug") }' + ) + await writeFile(join(routesDir, '_internal', 'helper.ts'), 'export const helper = true') + + const routes = await discoverRoutes(projectDir, createRouteConfig({ + name: 'route-discovery-test' + })) + + expect(routes?.dir).toBe('src/routes') + const routePaths = routes?.routes.map((route) => route.routePath) ?? [] + expect(routePaths).toEqual( + expect.arrayContaining(['/', '/users', '/users/[id]', '/users/[...slug]']) + ) + expect(routePaths.indexOf('/users/[id]')).toBeLessThan(routePaths.indexOf('/users/[...slug]')) + expect(routes?.routes.some((route) => route.filePath.includes('_internal'))).toBe(false) + }) + + test('applies files.routes prefix to discovered route paths', async () => { + const projectDir = await createTempProject() + const routesDir = join(projectDir, 'app-routes') + + await mkdir(join(routesDir, 'users'), { recursive: true }) + await writeFile( + join(routesDir, 'users', '[id].ts'), + 'export async function GET() { return new Response("user") }' + ) + + const routes = await discoverRoutes(projectDir, createRouteConfig({ + name: 'route-discovery-prefix-test', + files: { + routes: { + dir: 'app-routes', + prefix: '/api' + } + } + })) + + expect(routes?.prefix).toBe('/api') + expect(routes?.routes.map((route) => route.routePath)).toEqual(['/api/users/[id]']) + }) + + test('rejects conflicting route files that normalize to the same pattern', async () => { + const projectDir = await createTempProject() + const routesDir = join(projectDir, DEFAULT_ROUTE_DIR, 'users') + + await mkdir(routesDir, { recursive: true }) + await writeFile(join(routesDir, '[id].ts'), 'export async function GET() { return new Response("id") }') + await writeFile(join(routesDir, '[slug].ts'), 'export async function GET() { return new Response("slug") }') + + await expect(discoverRoutes(projectDir, createRouteConfig({ + name: 'route-conflict-test' + }))).rejects.toThrow('Conflicting file routes detected') + }) +}) diff --git a/packages/devflare/tsconfig.json b/packages/devflare/tsconfig.json new file mode 100644 index 0000000..c6d356e --- /dev/null +++ b/packages/devflare/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["@cloudflare/workers-types", "@types/bun"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dcc4097 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types", "@types/bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowImportingTsExtensions": true, + "paths": { + "devflare": ["./packages/devflare/src/index.ts"], + "devflare/*": ["./packages/devflare/src/*.ts"] + } + }, + "include": ["packages/*/src/**/*.ts", "packages/*/tests/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..bb724ca --- /dev/null +++ b/turbo.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [ + "bun.lock", + "package.json", + "tsconfig.json", + "cases/tsconfig.base.json", + "biome.json", + ".env.example" + ], + "tasks": { + "transit": { + "dependsOn": [ + "^transit" + ] + }, + "build": { + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + ".svelte-kit/**", + ".devflare/**", + ".wrangler/deploy/**", + "env.d.ts", + "src/lib/paraglide/**" + ] + }, + "test": { + "dependsOn": [ + "^build", + "transit" + ], + "outputs": [] + }, + "test:watch": { + "cache": false, + "persistent": true + }, + "types": { + "dependsOn": [ + "^build", + "transit" + ], + "outputs": [ + "env.d.ts" + ] + }, + "typecheck": { + "dependsOn": [ + "transit" + ], + "outputs": [] + }, + "check": { + "dependsOn": [ + "^build", + "transit" + ], + "outputs": [ + ".svelte-kit/**", + "src/lib/paraglide/**", + "env.d.ts" + ] + }, + "dev": { + "cache": false, + "persistent": true + }, + "deploy": { + "cache": false + }, + "//#lint:root": { + "outputs": [] + }, + "//#typecheck:root": { + "outputs": [] + } + } +} \ No newline at end of file